diff --git a/frontend/package.json b/frontend/package.json index 4000f098f..cfdf547bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,12 +15,10 @@ "build": "NODE_OPTIONS=--max_old_space_size=16384 rsbuild build", "build-local-test": "REACT_APP_BUSINESS=true REACT_APP_CONSOLE_GIT_SHA=abc123 REACT_APP_CONSOLE_GIT_REF=local REACT_APP_BUILD_TIMESTAMP=32503680000 REACT_APP_DEV_HINT=true NODE_OPTIONS=--max_old_space_size=16384 rsbuild build", "install:chromium": "bunx playwright install chromium", - "e2e-test": "playwright test -c playwright.config.ts tests/console/", - "e2e-test-enterprise": "playwright test tests/console-enterprise/ -c playwright.enterprise.config.ts", - "e2e-test:ui": "playwright test tests/console/ -c playwright.config.ts --ui", - "e2e-test-enterprise:ui": "playwright test -c playwright.enterprise.config.ts tests/console-enterprise/ --ui", - "e2e-test:dev": "E2E_DEV_MODE=true playwright test tests/console/ -c playwright.config.ts --ui", - "e2e-test-enterprise:dev": "E2E_DEV_MODE=true playwright test -c playwright.enterprise.config.ts tests/console-enterprise/ --ui", + "e2e-test": "node tests/scripts/run-all-variants.mjs", + "e2e-test:variant": "node tests/scripts/run-variant.mjs", + "e2e-test:dev": "E2E_DEV_MODE=true node tests/scripts/run-variant.mjs console --ui", + "e2e-test-enterprise:dev": "E2E_DEV_MODE=true node tests/scripts/run-variant.mjs console-enterprise --ui", "test:ci": "bun run test:unit && bun run test:integration", "test": "bun run test:ci", "test:unit": "TZ=GMT vitest run --config=vitest.config.unit.mts", diff --git a/frontend/playwright.enterprise.config.ts b/frontend/playwright.enterprise.config.ts deleted file mode 100644 index 5dd8e736d..000000000 --- a/frontend/playwright.enterprise.config.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; -import dotenv from 'dotenv'; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); -dotenv.config(); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - timeout: 120 * 1000, // 2 minutes for overall test timeout (increased for CI stability) - expect: { - timeout: 60 * 1000, - }, - testDir: './tests', - /* Run tests in files in parallel */ - fullyParallel: !!process.env.CI, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Number of parallel workers on CI - reduced for enterprise tests with multiple backend containers */ - workers: process.env.CI ? 2 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: process.env.CI ? 'list' : 'html', - /* Global setup and teardown */ - globalSetup: './tests/global-setup.mjs', - globalTeardown: './tests/global-teardown.mjs', - /* Custom metadata for setup/teardown */ - metadata: { - isEnterprise: true, - needsShadowlink: true, // Enables two-cluster setup for shadowlink tests - }, - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - navigationTimeout: 30 * 1000, - actionTimeout: 30 * 1000, - viewport: { width: 1920, height: 1080 }, - headless: !!process.env.CI, - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: process.env.REACT_APP_ORIGIN ?? 'http://localhost:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - - /* Disable screenshots and videos in CI for better performance - traces are more useful anyway */ - screenshot: 'off', - video: 'off', - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'authenticate', - testMatch: /auth\.setup\.ts/, - }, - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - // Use prepared auth state. - storageState: 'playwright/.auth/user.json', - }, - dependencies: ['authenticate'], - }, - ], -}); diff --git a/frontend/tests/README.md b/frontend/tests/README.md index b25a276fa..682bb9617 100644 --- a/frontend/tests/README.md +++ b/frontend/tests/README.md @@ -91,6 +91,252 @@ bun run e2e-test:teardown - šŸŽÆ Run only the tests you're working on - šŸ–„ļø Keep services running between test runs +## Test Variants + +This project supports multiple test variants for different configurations. Each variant has its own: +- Configuration file (`tests/config/console.[variant].config.yaml`) +- Test directory (`tests/console-[variant]/`) +- Playwright config (`playwright.[variant].config.ts`) +- Isolated port allocations (no conflicts) +- Independent container lifecycle + +### Available Variants + +View all configured variants: +```bash +bun run variant:list +``` + +**Currently configured:** +- **console** (OSS) - Open-source tests without enterprise features +- **console-enterprise** (Enterprise) - Enterprise tests with authentication and shadowlink + +### Running Variant Tests + +```bash +# Run OSS tests +bun run e2e-test + +# Run Enterprise tests +bun run e2e-test-enterprise + +# Run with UI mode +bun run e2e-test:ui +bun run e2e-test-enterprise:ui + +# Run using variant CLI +bun run variant run console +bun run variant run console-enterprise +``` + +### Parallel Execution + +Variants use isolated ports, enabling parallel execution: + +```bash +# Terminal 1 - Run OSS tests +bun run e2e-test + +# Terminal 2 - Run Enterprise tests simultaneously +bun run e2e-test-enterprise + +# No port conflicts! +``` + +### Port Allocation + +Each variant uses a dedicated port range (offset by +100): + +| Variant | Backend | Kafka | Schema Registry | Admin API | Connect | +|---------|---------|-------|-----------------|-----------|---------| +| console | 3000 | 19092 | 18081 | 19644 | 18083 | +| console-enterprise | 3100 | 19192 | 18181 | 19744 | 18183 | +| console-custom1 | 3200 | 19292 | 18281 | 19844 | 18283 | + +**Formula:** `port = basePort + (variantIndex Ɨ 100)` + +This prevents conflicts and allows running up to 600+ variants simultaneously. + +### Creating a New Variant + +Step-by-step guide to create a new test variant: + +**1. Scaffold the variant:** +```bash +bun run variant:create console-qa +``` + +This creates: +- `tests/console-qa/` - Test directory +- `tests/config/console.qa.config.yaml` - Backend config (copied from OSS template) +- `playwright.qa.config.ts` - Playwright config +- `tests/console-qa/smoke.spec.ts` - Sample test + +**2. Add to variant registry:** + +Edit `tests/variants.config.mjs` and add your variant: + +```javascript +'console-qa': { + name: 'console-qa', + displayName: 'QA', + configFile: 'console.qa.config.yaml', + testDir: 'console-qa', + metadata: { + isEnterprise: false, // Change to true if enterprise features needed + needsShadowlink: false // Change to true if shadowlink needed + }, + ports: { + backend: 3200, + redpandaKafka: 19292, + redpandaSchemaRegistry: 18281, + redpandaAdmin: 19844, + kafkaConnect: 18283 + } +} +``` + +**3. Add package.json script:** + +```json +{ + "scripts": { + "e2e-test-qa": "playwright test tests/console-qa/ -c playwright.qa.config.ts" + } +} +``` + +**4. Customize configuration:** + +Edit `tests/config/console.qa.config.yaml` to customize backend settings: +- Feature flags +- Authentication settings +- Cluster connections +- Service endpoints + +**5. Write your tests:** + +Add test files in `tests/console-qa/`: + +```typescript +// tests/console-qa/my-feature.spec.ts +import { test, expect } from '@playwright/test'; + +test('QA environment smoke test', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/Redpanda Console/); +}); +``` + +**6. Run your tests:** + +```bash +bun run e2e-test-qa +``` + +### Variant CLI Commands + +```bash +# List all variants with details +bun run variant:list + +# Create a new variant +bun run variant:create console-staging + +# Validate configuration +bun run variant:validate + +# Run variant tests +bun run variant run console-qa +``` + +### Validation + +Validate your variant configuration: + +```bash +bun run variant:validate +``` + +This checks for: +- āœ… Port conflicts between variants +- āœ… Missing test directories +- āœ… Unconfigured test directories +- āœ… Missing configuration files +- āœ… Invalid variant definitions + +### Variant Architecture + +``` +tests/ +ā”œā”€ā”€ variants.config.mjs # Single source of truth for all variants +ā”œā”€ā”€ playwright-config-factory.mjs # Generates variant-specific configs +ā”œā”€ā”€ variant-cli.mjs # Management CLI +ā”œā”€ā”€ global-setup.mjs # Variant-aware container setup +ā”œā”€ā”€ global-teardown.mjs # Variant-aware cleanup +ā”œā”€ā”€ config/ +│ ā”œā”€ā”€ console.config.yaml # OSS config +│ ā”œā”€ā”€ console.enterprise.config.yaml # Enterprise config +│ ā”œā”€ā”€ console.qa.config.yaml # Custom variant config +│ └── Dockerfile.backend # Backend container image +ā”œā”€ā”€ console/ # OSS tests +│ ā”œā”€ā”€ topics/ +│ └── connectors/ +ā”œā”€ā”€ console-enterprise/ # Enterprise tests +│ ā”œā”€ā”€ users.spec.ts +│ ā”œā”€ā”€ acl.spec.ts +│ └── shadowlink/ +└── console-qa/ # Custom variant tests + └── smoke.spec.ts +``` + +### Container State Files + +Each variant maintains its own container state file: +- `.testcontainers-state-console.json` +- `.testcontainers-state-console-enterprise.json` +- `.testcontainers-state-console-qa.json` + +This allows independent lifecycle management per variant. + +### Troubleshooting Variants + +**Port Already in Use:** +```bash +# Check what's using a port +lsof -i :3100 + +# Stop all test containers +docker ps | grep -E "redpanda|console-backend|owlshop|connect" | awk '{print $1}' | xargs docker stop +``` + +**Variant Not Found:** +```bash +# Check available variants +bun run variant:list + +# Validate configuration +bun run variant:validate +``` + +**Configuration Issues:** +```bash +# Run validation to diagnose +bun run variant:validate + +# Check variant registry +cat tests/variants.config.mjs +``` + +**Stale Container State:** +```bash +# Remove state files to force cleanup +rm tests/.testcontainers-state-*.json + +# Restart Docker +docker restart +``` + ## Services The following services are automatically started: diff --git a/frontend/tests/config/console.dev.config.yaml b/frontend/tests/config/console.dev.config.yaml deleted file mode 100644 index 5025dc491..000000000 --- a/frontend/tests/config/console.dev.config.yaml +++ /dev/null @@ -1,31 +0,0 @@ -serveFrontend: false - -kafka: - brokers: ["redpanda:9092"] - sasl: - enabled: true - mechanism: SCRAM-SHA-256 - username: e2euser - password: very-secret - impersonateUser: false - -schemaRegistry: - enabled: true - urls: ["http://redpanda:8081"] - -redpanda: - adminApi: - enabled: true - urls: ["http://redpanda:9644"] - -kafkaConnect: - enabled: true - clusters: - - name: local-connect-cluster - url: http://connect:8083 - - name: local-connect-cluster2 - url: http://connect:8083 - -server: - listenPort: 3000 - allowedOrigins: ["http://localhost:3000", "http://localhost:3001"] diff --git a/frontend/tests/config/console.enterprise.dev.config.yaml b/frontend/tests/config/console.enterprise.dev.config.yaml deleted file mode 100644 index e2f2dcab3..000000000 --- a/frontend/tests/config/console.enterprise.dev.config.yaml +++ /dev/null @@ -1,38 +0,0 @@ -serveFrontend: false - -kafka: - brokers: ["redpanda:9092"] - sasl: - enabled: true - mechanism: SCRAM-SHA-256 - username: e2euser - password: very-secret - impersonateUser: false - -schemaRegistry: - enabled: true - urls: ["http://redpanda:8081"] - -redpanda: - adminApi: - enabled: true - urls: ["http://redpanda:9644"] - -kafkaConnect: - enabled: true - clusters: - - name: local-connect-cluster - url: http://connect:8083 - - name: local-connect-cluster2 - url: http://connect:8083 - -server: - listenPort: 3000 - allowedOrigins: ["http://localhost:3000", "http://localhost:3001"] - -license: - filepath: /etc/console/redpanda.license - -rbac: - enabled: true - roleBindingsFilepath: /etc/console/role-bindings.yaml diff --git a/frontend/tests/console-enterprise/shadowlink/shadowlink.spec.ts b/frontend/tests/console-enterprise/shadowlink/shadowlink.spec.ts deleted file mode 100644 index c182348f7..000000000 --- a/frontend/tests/console-enterprise/shadowlink/shadowlink.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** biome-ignore-all lint/performance/useTopLevelRegex: e2e test */ -import { expect, test } from '@playwright/test'; -import { ShadowlinkPage, generateShadowlinkName } from '../../console/utils/shadowlink-page'; - -test.describe('Shadow Link E2E Tests', () => { - test.describe('Shadow Link Creation', () => { - test('should fill connection step successfully', async ({ page }) => { - const shadowlinkPage = new ShadowlinkPage(page); - const shadowlinkName = generateShadowlinkName(); - - await test.step('Navigate to create page', async () => { - await shadowlinkPage.gotoCreate(); - }); - - await test.step('Fill connection details with SCRAM', async () => { - await shadowlinkPage.fillConnectionStep({ - name: shadowlinkName, - bootstrapServers: 'redpanda:9092', // Source cluster (existing) - username: 'e2euser', - password: 'very-secret', - mechanism: 'SCRAM-SHA-256', - }); - }); - - await test.step('Verify we reached configuration step', async () => { - // After clicking Next, we should be on step 2 (Configuration) - await expect(page.getByRole('heading', { name: /configuration/i })).toBeVisible(); - }); - }); - - test('should create, update, failover, and delete shadowlink', async ({ page }) => { - const shadowlinkPage = new ShadowlinkPage(page); - const shadowlinkName = generateShadowlinkName(); - - await test.step('Create shadowlink with literal filter', async () => { - await shadowlinkPage.createShadowlink({ - name: shadowlinkName, - bootstrapServers: 'redpanda:9092', // Source cluster - username: 'e2euser', - password: 'very-secret', - topicFilters: [ - { - type: 'LITERAL', - filter: 'INCLUDE', - pattern: 'owlshop-orders', - }, - ], - }); - }); - - await test.step('Verify shadowlink appears on home page', async () => { - await shadowlinkPage.verifyOnHomePage(shadowlinkName); - }); - - await test.step('Verify topic synced on details page', async () => { - await shadowlinkPage.gotoDetails(shadowlinkName); - await shadowlinkPage.verifyTopicExists('owlshop-orders'); - }); - - await test.step('Update to prefix filter', async () => { - await shadowlinkPage.gotoEdit(shadowlinkName); - await shadowlinkPage.updateTopicFilters([ - { - type: 'PREFIX', - filter: 'INCLUDE', - pattern: 'owlshop-', - }, - ]); - }); - - await test.step('Failover single topic and wait for all topics', async () => { - await shadowlinkPage.gotoDetails(shadowlinkName); - // Failover single topic (will verify state change) - await shadowlinkPage.failoverTopic('owlshop-orders'); - - }); - - await test.step('Failover all topics', async () => { - // Wait for all 7 topics to sync (uses metrics which is faster) - await shadowlinkPage.verifyMetrics({ - totalTopics: 7, - }); - await shadowlinkPage.performFailover(); - }); - - await test.step('Verify all topics failed over', async () => { - await shadowlinkPage.gotoDetails(shadowlinkName); - await shadowlinkPage.verifyMetrics({ - totalTopics: 7, - failedOverTopics: 7, - errorTopics: 0, - }); - }); - - await test.step('Delete shadowlink', async () => { - await shadowlinkPage.deleteShadowlink(); - }); - - await test.step('Verify shadowlink deleted', async () => { - await shadowlinkPage.verifyNotInList(shadowlinkName); - }); - }); - }); - - test.describe('Shadow Link Filter', () => { - test.skip(!!process.env.CI, 'Flaky in CI - timing issues with metrics loading'); - test('should create with exclude filter and verify it works', async ({ page }) => { - const shadowlinkPage = new ShadowlinkPage(page); - const shadowlinkName = generateShadowlinkName(); - - await test.step('Create with include-all and exclude addresses filter', async () => { - await shadowlinkPage.createShadowlink({ - name: shadowlinkName, - bootstrapServers: 'redpanda:9092', // Source cluster - username: 'e2euser', - password: 'very-secret', - topicFilters: [ - { - type: 'LITERAL', - filter: 'INCLUDE', - pattern: '*', - }, - { - type: 'LITERAL', - filter: 'EXCLUDE', - pattern: 'owlshop-addresses', - }, - ], - }); - }); - - await test.step('Wait for topics to sync (excluding addresses)', async () => { - await shadowlinkPage.gotoDetails(shadowlinkName); - // Wait for 9 topics (10 total - 1 excluded addresses) - await shadowlinkPage.verifyMetrics({ - totalTopics: 9, - }); - }); - - await test.step('Verify addresses topic is excluded', async () => { - // Should not see owlshop-addresses in the topics list - await expect(page.getByTestId('shadowlink-topic-owlshop-addresses')).not.toBeVisible(); - }); - }); - }); -}); \ No newline at end of file diff --git a/frontend/tests/console/utils/shadowlink-page.ts b/frontend/tests/console/utils/shadowlink-page.ts deleted file mode 100644 index 49f61b3c4..000000000 --- a/frontend/tests/console/utils/shadowlink-page.ts +++ /dev/null @@ -1,633 +0,0 @@ -/** biome-ignore-all lint/performance/useTopLevelRegex: e2e test */ -import type { Page } from '@playwright/test'; -import { expect } from '@playwright/test'; - -/** - * Helper to generate unique shadowlink names - */ -export function generateShadowlinkName() { - const timestamp = Date.now().toString().slice(-8); - const random = Math.random().toString(36).substring(2, 6); - return `e2e-shadowlink-${timestamp}-${random}`; -} - -/** - * Page Object Model for Shadow Link pages - * Handles shadowlink list, create, edit, details, and verification - * - * Shadowlinks are created on the DESTINATION cluster console (port 3001) - * to pull data FROM the source cluster - */ -export class ShadowlinkPage { - readonly page: Page; - readonly baseURL: string; - - constructor(page: Page, baseURL: string = 'http://localhost:3001') { - this.page = page; - this.baseURL = baseURL; - } - - /** - * Navigation methods - */ - async goto() { - await this.page.goto(`${this.baseURL}/shadowlinks`); - await expect(this.page.getByRole('heading', { name: /shadow links/i })).toBeVisible({ timeout: 10000 }); - } - - async gotoCreate() { - await this.page.goto(`${this.baseURL}/shadowlinks/create`); - await expect(this.page.getByRole('heading', { name: /create shadow link/i })).toBeVisible({ timeout: 10000 }); - } - - async gotoDetails(name: string) { - await this.page.goto(`${this.baseURL}/shadowlinks/${encodeURIComponent(name)}`); - await expect(this.page.getByRole('heading', { name })).toBeVisible({ - timeout: 10000, - }); - } - - async gotoEdit(name: string) { - await this.page.goto(`${this.baseURL}/shadowlinks/${encodeURIComponent(name)}/edit`); - await expect(this.page.getByRole('heading', { name: /edit shadow link/i })).toBeVisible({ timeout: 10000 }); - } - - /** - * Create wizard - Step 1: Connection - */ - async fillConnectionStep(params: { - name: string; - bootstrapServers: string; - username?: string; - password?: string; - mechanism?: 'SCRAM-SHA-256' | 'SCRAM-SHA-512'; - }) { - // Fill name using label - const nameInput = this.page.getByLabel(/shadow link name/i); - await nameInput.fill(params.name); - - // Fill bootstrap servers using testId (first input field) - const bootstrapInput = this.page.getByTestId('bootstrap-server-input-0'); - await bootstrapInput.fill(params.bootstrapServers); - - // Ensure TLS is turned OFF - const tlsToggle = this.page.getByTestId('tls-toggle'); - const isTlsChecked = await tlsToggle.isChecked(); - if (isTlsChecked) { - await tlsToggle.click(); - } - - // Configure SASL if provided - if (params.username && params.password) { - // Find and toggle SCRAM switch - look for the switch near "SCRAM" text - const scramSection = this.page.getByText('SCRAM').locator('..'); - const scramToggle = scramSection.getByRole('switch'); - - // Check if SCRAM is already enabled - const isScramEnabled = await scramToggle.isChecked(); - if (!isScramEnabled) { - await scramToggle.click(); - // Wait for username field to appear after toggle - await this.page.getByLabel(/username/i).waitFor({ state: 'visible', timeout: 5000 }); - } - - // Fill username and password using labels - const usernameInput = this.page.getByLabel(/username/i); - await usernameInput.fill(params.username); - - const passwordInput = this.page.getByLabel(/password/i); - await passwordInput.fill(params.password); - - if (params.mechanism) { - // Find mechanism combobox and click it - const mechanismButton = this.page.getByLabel(/mechanism/i); - await mechanismButton.click(); - - // Wait for dropdown to open and select the option - await this.page.getByRole('option', { name: params.mechanism }).click(); - } - } - - // Click Next button in stepper - const nextButton = this.page.getByRole('button', { name: /next/i }); - await nextButton.click(); - } - - /** - * Create wizard - Step 2: Configuration - */ - async fillConfigurationStep(params: { - topicFilters?: Array<{ - type: 'PREFIX' | 'LITERAL'; - filter: 'INCLUDE' | 'EXCLUDE'; - pattern: string; - }>; - syncInterval?: number; - consumerOffsets?: boolean; - aclSync?: boolean; - schemaRegistry?: boolean; - }) { - // Configure topic filters - if (params.topicFilters && params.topicFilters.length > 0) { - // Click "Specify topics" tab - const specifyTopicsTab = this.page.getByTestId('topics-specify-tab'); - await specifyTopicsTab.click(); - - // Wait for filters container to be visible - await this.page.getByTestId('topics-filters-container').waitFor({ state: 'visible' }); - - for (let i = 0; i < params.topicFilters.length; i++) { - const filter = params.topicFilters[i]; - - if (i > 0) { - // Add new filter - const addFilterBtn = this.page.getByTestId('add-topic-filter-button'); - await addFilterBtn.click(); - } - - // Determine the tab value based on filter type and pattern type - let tabValue = 'include-specific'; // default - if (filter.type === 'LITERAL' && filter.filter === 'INCLUDE') { - tabValue = 'include-specific'; - } else if (filter.type === 'PREFIX' && filter.filter === 'INCLUDE') { - tabValue = 'include-prefix'; - } else if (filter.type === 'LITERAL' && filter.filter === 'EXCLUDE') { - tabValue = 'exclude-specific'; - } else if (filter.type === 'PREFIX' && filter.filter === 'EXCLUDE') { - tabValue = 'exclude-prefix'; - } - - // Click the appropriate tab trigger - const tabTrigger = this.page.getByTestId(`topic-filter-${i}-${tabValue}`); - await tabTrigger.click(); - - // Fill in the pattern - const patternInput = this.page.getByTestId(`topic-filter-${i}-name`); - await patternInput.fill(filter.pattern); - } - } - - // Configure sync interval - if (params.syncInterval) { - const intervalInput = this.page.getByTestId('shadowlink-sync-interval-input'); - await intervalInput.fill(params.syncInterval.toString()); - } - - // Toggle consumer offsets - if (params.consumerOffsets !== undefined) { - const toggle = this.page.getByTestId('shadowlink-consumer-offsets-toggle'); - const isChecked = await toggle.isChecked(); - if (isChecked !== params.consumerOffsets) { - await toggle.click(); - } - } - - // Toggle ACL sync - if (params.aclSync !== undefined) { - const toggle = this.page.getByTestId('shadowlink-acl-sync-toggle'); - const isChecked = await toggle.isChecked(); - if (isChecked !== params.aclSync) { - await toggle.click(); - } - } - - // Toggle schema registry sync - if (params.schemaRegistry !== undefined) { - const toggle = this.page.getByTestId('shadowlink-schema-registry-toggle'); - const isChecked = await toggle.isChecked(); - if (isChecked !== params.schemaRegistry) { - await toggle.click(); - } - } - - // Click Create button and wait for either navigation or error - const createButton = this.page.getByRole('button', { name: /create/i }); - - // Wait for button to be enabled and clickable - await expect(createButton).toBeEnabled({ timeout: 5000 }); - await createButton.click(); - } - - /** - * Complete create flow - */ - async createShadowlink(params: { - name: string; - bootstrapServers: string; - username?: string; - password?: string; - topicFilters?: Array<{ - type: 'PREFIX' | 'LITERAL'; - filter: 'INCLUDE' | 'EXCLUDE'; - pattern: string; - }>; - }) { - await this.gotoCreate(); - await this.fillConnectionStep(params); - await this.fillConfigurationStep({ topicFilters: params.topicFilters }); - - // Wait for navigation to details page or stay on list page - try { - await expect(this.page).toHaveURL(/\/shadowlinks\/.+/, { timeout: 30000 }); - } catch (error) { - // If navigation didn't happen, check if we're on the list page - // This might mean creation succeeded but returned to list instead of details - const currentURL = this.page.url(); - console.log(`Shadowlink creation completed. Current URL: ${currentURL}`); - - // Check if there's an error message - const errorMessage = this.page.getByRole('alert').or(this.page.getByText(/error|failed/i)); - const hasError = await errorMessage.count() > 0; - if (hasError) { - const errorText = await errorMessage.first().textContent(); - throw new Error(`Shadowlink creation failed: ${errorText}`); - } - - // If we're on the list page without errors, the shadowlink was created - // but we need to navigate to it manually - if (currentURL.includes('/shadowlinks') && !currentURL.match(/\/shadowlinks\/.+/)) { - console.log('Shadowlink created but stayed on list page. Navigating to details...'); - // Wait a moment for the shadowlink to appear in the list - await this.page.waitForTimeout(2000); - } - } - } - - /** - * Details page - Verification methods - */ - async verifyInList(name: string) { - await this.goto(); - const linkElement = this.page.getByRole('link', { name, exact: true }); - await expect(linkElement).toBeVisible({ timeout: 10000 }); - } - - async verifyNotInList(name: string) { - await this.goto(); - const linkElement = this.page.getByRole('link', { name, exact: true }); - await expect(linkElement).not.toBeVisible(); - } - - async verifyOnHomePage(shadowlinkName: string, timeout = 30000) { - // Navigate to home page (root after login) - await this.page.goto(this.baseURL); - - // Wait for home page to load - look for common elements - await expect(this.page.getByRole('heading', { name: /overview|dashboard|home/i })).toBeVisible({ timeout: 10000 }); - - // Verify shadowlink section appears with retry logic - // The Shadow Cluster section may take time to appear after shadowlink creation - await expect(async () => { - await this.page.reload({ waitUntil: 'domcontentloaded', timeout: 5000 }); - - // Verify "Shadow Cluster" heading exists - const shadowClusterHeading = this.page.getByRole('heading', { name: /shadow cluster/i }); - await expect(shadowClusterHeading).toBeVisible(); - - // Verify the "Go to Shadow link" button exists - const goToShadowLinkButton = this.page.getByRole('button', { name: /go to shadow link/i }); - await expect(goToShadowLinkButton).toBeVisible(); - }).toPass({ timeout, intervals: [2000, 3000] }); - } - - async verifyStatus(expectedStatus: 'ACTIVE' | 'PAUSED' | 'FAILED_OVER', timeout = 30000) { - await expect(async () => { - await this.page.reload({ waitUntil: 'domcontentloaded', timeout: 5000 }); - const statusBadge = this.page.getByTestId('shadowlink-status-badge'); - await expect(statusBadge).toContainText(expectedStatus); - }).toPass({ timeout, intervals: [2000, 3000] }); - } - - async verifyTopicCount(expectedCount: number, timeout = 30000) { - // Wait for topics to sync - await expect(async () => { - const topicCountElement = this.page.getByTestId('shadowlink-topic-count'); - const text = await topicCountElement.textContent(); - const count = Number.parseInt(text || '0', 10); - expect(count).toBe(expectedCount); - }).toPass({ timeout }); - } - - async verifyTopicExists(topicName: string) { - // Look for the topic name in the topics table - const topicElement = this.page.getByText(topicName, { exact: true }); - await expect(topicElement).toBeVisible({ timeout: 30000 }); - } - - async verifyMetrics(expectedMetrics: { - totalTopics?: number; - failedOverTopics?: number; - errorTopics?: number; - }, timeout = 90000) { - // Poll metrics values with periodic refresh button clicks - await expect(async () => { - // Try to click refresh button on replicated topics card to trigger refetch - const replicatedCard = this.page.getByTestId('shadow-link-metric-replicated'); - await replicatedCard.hover({ timeout: 500 }); - const refreshBtn = replicatedCard.locator('button:has(svg.lucide-refresh-cw)'); - await refreshBtn.click({ force: true, timeout: 500 }); - - // Check the values - if (expectedMetrics.totalTopics !== undefined) { - const totalElement = this.page.getByTestId('metric-value-replicated'); - const actualValue = await totalElement.textContent(); - expect(actualValue).toBe(expectedMetrics.totalTopics.toString()); - } - - if (expectedMetrics.failedOverTopics !== undefined) { - const failedOverElement = this.page.getByTestId('metric-value-failedover'); - const actualValue = await failedOverElement.textContent(); - expect(actualValue).toBe(expectedMetrics.failedOverTopics.toString()); - } - - if (expectedMetrics.errorTopics !== undefined) { - const errorElement = this.page.getByTestId('metric-value-error'); - const actualValue = await errorElement.textContent(); - expect(actualValue).toBe(expectedMetrics.errorTopics.toString()); - } - }).toPass({ timeout, intervals: [3000] }); - } - - /** - * Edit methods - */ - async updateTopicFilters(filters: Array<{ - type: 'PREFIX' | 'LITERAL'; - filter: 'INCLUDE' | 'EXCLUDE'; - pattern: string; - }>) { - // Navigate to Shadowing tab - const shadowingTab = this.page.getByRole('tab', { name: /shadowing/i }); - await shadowingTab.click(); - - // Wait for the tab content to load - await this.page.waitForTimeout(500); - - // Find the "Shadow topics" section - const shadowTopicsHeading = this.page.getByRole('heading', { name: /shadow topics/i }); - await shadowTopicsHeading.waitFor({ state: 'visible' }); - - // ALWAYS try to expand the collapsible by clicking the chevron button - // The issue is that tabs might be visible in resume view but we need the full editable view - // The chevron button is right after the "Shadow topics" heading in the card header - // Simple approach: find all buttons with SVG, filter for the one that's actually a chevron icon - const buttonsForChevron = await this.page.getByRole('button').all(); - - let chevronClicked = false; - for (const btn of buttonsForChevron) { - // Check if this button contains an SVG with class matching chevron pattern - const isChevron = await btn.evaluate((button) => { - const svg = button.querySelector('svg'); - if (!svg) return false; - // ChevronDown has specific classes h-4 w-4 transition-transform - const classes = Array.from(svg.classList); - return classes.includes('lucide-chevron-down') || (classes.includes('h-4') && classes.includes('w-4') && classes.includes('transition-transform')); - }); - - if (isChevron) { - await btn.click(); - await this.page.waitForTimeout(800); - chevronClicked = true; - break; - } - } - - if (!chevronClicked) { - throw new Error('Could not find chevron button to expand Shadow topics section'); - } - - // Now click the "Specify topics" tab - const specifyTab = this.page.getByRole('tab', { name: /specify topics/i }); - await specifyTab.waitFor({ state: 'visible', timeout: 5000 }); - await specifyTab.click(); - - // Wait for filter items to be visible - await this.page.waitForTimeout(1000); - - // Clear existing filters - look for delete/trash buttons in elevated cards - // Keep finding and clicking trash buttons until none remain - let foundTrashButton = true; - while (foundTrashButton) { - foundTrashButton = false; - const allButtons = await this.page.getByRole('button').all(); - - for (const btn of allButtons) { - try { - const isTrashButton = await btn.evaluate((button) => { - const svg = button.querySelector('svg'); - return svg?.classList.contains('lucide-trash'); - }); - - if (isTrashButton) { - await btn.click(); - await this.page.waitForTimeout(300); - foundTrashButton = true; - break; // Exit inner loop and re-query buttons - } - } catch (e) { - // Button may have been removed from DOM, continue to next - } - } - } - - // Add new filters - // After deleting all filters, we need to add new ones by clicking "Add filter" for each - let filterIndex = 0; - for (const filter of filters) { - // Click "Add filter" button to create a new filter card - const addBtn = this.page.getByRole('button', { name: /add filter/i }); - await addBtn.click(); - await this.page.waitForTimeout(800); - - // Determine the tab label based on filter type and pattern type - let tabLabel = ''; - if (filter.type === 'LITERAL' && filter.filter === 'INCLUDE') { - tabLabel = 'Include specific topics'; - } else if (filter.type === 'PREFIX' && filter.filter === 'INCLUDE') { - tabLabel = 'Include starting with'; - } else if (filter.type === 'LITERAL' && filter.filter === 'EXCLUDE') { - tabLabel = 'Exclude specific'; - } else if (filter.type === 'PREFIX' && filter.filter === 'EXCLUDE') { - tabLabel = 'Exclude starting with'; - } - - // Find all tabs with this label and click the one for this filter (filterIndex) - const allTabsWithLabel = this.page.getByRole('tab', { name: tabLabel }); - const targetTab = allTabsWithLabel.nth(filterIndex); - await targetTab.waitFor({ state: 'visible', timeout: 5000 }); - await targetTab.click(); - await this.page.waitForTimeout(300); - - // Use testId to find the specific input field directly - // The input has testId="topic-filter-{index}-name" - const inputInCard = this.page.locator(`[data-testid="topic-filter-${filterIndex}-name"]`); - await inputInCard.click(); - await inputInCard.fill(filter.pattern); - await this.page.waitForTimeout(200); - - filterIndex++; - } - - // TODO: this is a bug that needs resolving. - // Before saving, we need to go to the Source tab and fill the password - // The form requires SCRAM password even if we're only changing topic filters - const sourceTab = this.page.getByRole('tab', { name: /source/i }); - await sourceTab.click(); - await this.page.waitForTimeout(500); - - // Fill in the password field (required by form validation) - const passwordInput = this.page.getByRole('textbox', { name: /password/i }); - await passwordInput.click(); - await passwordInput.fill('very-secret'); - await this.page.waitForTimeout(200); - - // Save changes - const saveButton = this.page.getByRole('button', { name: /save/i }); - await saveButton.click(); - - // Wait for redirect back to details page (the save is successful if we're redirected) - // The URL should change from /shadowlinks/{name}/edit to /shadowlinks/{name} - await expect(this.page).toHaveURL(/\/shadowlinks\/[^/]+$/, { - timeout: 10000, - }); - } - - /** - * Actions - */ - async performFailover() { - // Find "Failover all topics" button specifically (not individual topic failover buttons) - const failoverButton = this.page.getByRole('button', { name: /^failover all topics$/i }); - await failoverButton.click(); - - // Confirm in dialog - const confirmButton = this.page.getByRole('button', { name: /confirm|yes|failover/i }).last(); - await confirmButton.click(); - - // Wait a moment for failover to initiate - await this.page.waitForTimeout(1000); - } - - async failoverTopic(topicName: string) { - // Find the topic row and click failover action (should be on Overview tab) - const topicRow = this.page.getByRole('row').filter({ hasText: topicName }); - const failoverButton = topicRow.getByRole('button', { name: /failover/i }); - await failoverButton.click(); - - // Confirm in dialog - const confirmButton = this.page.getByRole('button', { name: /confirm|yes|failover/i }).last(); - await confirmButton.click(); - - // Wait a moment for failover to process - // await this.page.waitForTimeout(1000); - - // Click the refresh button for the replicated topics table - const refreshButton = this.page.getByTestId('refresh-topics-button'); - await refreshButton.click(); - await this.page.waitForTimeout(500); // Wait for refresh to complete - - // Verify the replication state changed to "Failed over" - const topicRowAfterRefresh = this.page.getByRole('row').filter({ hasText: topicName }); - await expect(topicRowAfterRefresh.getByText(/failed over/i)).toBeVisible({ timeout: 5000 }); - } - - async deleteShadowlink() { - // Find delete button by role and name - const deleteButton = this.page.getByRole('button', { name: /delete/i }); - await deleteButton.click(); - - // Wait for dialog to appear - await this.page.waitForTimeout(500); - - // Type "delete" in the confirmation textbox - const confirmInput = this.page.getByRole('textbox', { name: /type.*delete|confirm/i }); - await confirmInput.fill('delete'); - - // Wait a moment for the delete button to become enabled - await this.page.waitForTimeout(200); - - // Click final confirm button in the dialog - const confirmButton = this.page.getByRole('button', { name: /delete/i }).last(); - await confirmButton.click(); - - // Wait for redirect to list - await expect(this.page).toHaveURL(`${this.baseURL}/shadowlinks`, { timeout: 15000 }); - - // Clean up replicated topics in destination cluster - await this.cleanupDestinationTopics(); - } - - async cleanupDestinationTopics() { - console.log('Cleaning up replicated topics in destination cluster...'); - try { - const { exec } = await import('node:child_process'); - const { promisify } = await import('node:util'); - const { readFileSync } = await import('node:fs'); - const { resolve } = await import('node:path'); - const execAsync = promisify(exec); - - // Read state file to get destination container ID - const stateFilePath = resolve(__dirname, '../../.testcontainers-state-enterprise.json'); - let containerId: string; - - try { - const stateContent = readFileSync(stateFilePath, 'utf-8'); - const state = JSON.parse(stateContent); - containerId = state.destRedpandaId; - - if (!containerId) { - console.log(' No destination container ID in state file, skipping cleanup'); - return; - } - } catch (readError) { - console.log(' Could not read state file, trying to find container by port...'); - // Fallback: try to find container by exposed port 19093 - const { stdout: containerByPort } = await execAsync('docker ps -q --filter "publish=19093"'); - containerId = containerByPort.trim(); - - if (!containerId) { - console.log(' No destination cluster container found, skipping cleanup'); - return; - } - } - - // List all topics in destination cluster with SASL auth - const saslFlags = '--user e2euser --password very-secret --sasl-mechanism SCRAM-SHA-256'; - const listCmd = `docker exec ${containerId.trim()} rpk topic list --brokers localhost:9092 ${saslFlags}`; - const { stdout } = await execAsync(listCmd); - - // Filter owlshop topics (the ones replicated by shadowlink) - const topics = stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.startsWith('owlshop-') && !line.includes('PARTITIONS')); - - if (topics.length === 0) { - console.log(' No topics to clean up'); - return; - } - - console.log(` Found ${topics.length} topics to delete: ${topics.join(', ')}`); - - // Delete each topic with SASL auth - for (const topic of topics) { - const deleteCmd = `docker exec ${containerId.trim()} rpk topic delete ${topic} --brokers localhost:9092 ${saslFlags}`; - await execAsync(deleteCmd); - console.log(` āœ“ Deleted topic: ${topic}`); - } - - console.log('āœ“ Cleanup complete'); - } catch (error) { - console.log(` Warning: Could not clean up destination topics: ${error.message}`); - } - } - - /** - * Helper methods - */ - async clickTab(tabName: 'Overview' | 'Tasks' | 'Configuration') { - const tab = this.page.getByRole('tab', { name: new RegExp(tabName, 'i') }); - await tab.click(); - await expect(tab).toHaveAttribute('aria-selected', 'true'); - } -} diff --git a/frontend/tests/scripts/discover-variants.mjs b/frontend/tests/scripts/discover-variants.mjs new file mode 100644 index 000000000..8ffa8afe5 --- /dev/null +++ b/frontend/tests/scripts/discover-variants.mjs @@ -0,0 +1,111 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testsDir = resolve(__dirname, '..'); + +// Default license file path for enterprise variants +const DEFAULT_LICENSE_PATH = resolve( + __dirname, + '../../../../console-enterprise/frontend/tests/config/redpanda.license' +); + +/** + * Check if a variant can run based on its requirements + * @param {object} config - Variant configuration + * @returns {{canRun: boolean, reason: string|null}} + */ +function checkVariantRequirements(config) { + // Check if license is required and available + if (config.requiresLicense) { + const licensePath = process.env.REDPANDA_LICENSE_PATH || DEFAULT_LICENSE_PATH; + const hasLicenseEnv = Boolean(process.env.ENTERPRISE_LICENSE_CONTENT); + const hasLicenseFile = existsSync(licensePath); + + if (!(hasLicenseEnv || hasLicenseFile)) { + return { + canRun: false, + reason: `License required but not found. Set ENTERPRISE_LICENSE_CONTENT env var or place license at ${licensePath}`, + }; + } + } + + return { canRun: true, reason: null }; +} + +/** + * Discovers all test variants by scanning for test-variant-* directories + * @param {{includeUnrunnable?: boolean}} options + * @returns {Array<{name: string, path: string, config: object, canRun: boolean, skipReason: string|null}>} + */ +export function discoverVariants(options = {}) { + const { includeUnrunnable = false } = options; + const variants = []; + + const entries = readdirSync(testsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('test-variant-')) { + const variantPath = join(testsDir, entry.name); + const configPath = join(variantPath, 'config', 'variant.json'); + + if (existsSync(configPath)) { + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + const { canRun, reason } = checkVariantRequirements(config); + + if (canRun || includeUnrunnable) { + variants.push({ + name: config.name, + dirName: entry.name, + path: variantPath, + config, + canRun, + skipReason: reason, + }); + } + } catch (error) { + console.error(`Error reading variant config at ${configPath}:`, error.message); + } + } else { + console.warn(`Variant directory ${entry.name} is missing config/variant.json`); + } + } + } + + return variants.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Get a specific variant by name + * @param {string} name - Variant name (e.g., "console") + * @returns {object|null} + */ +export function getVariant(name) { + const variants = discoverVariants(); + return variants.find((v) => v.name === name) || null; +} + +/** + * Get variant directory path by name + * @param {string} name - Variant name + * @returns {string|null} + */ +export function getVariantPath(name) { + const variant = getVariant(name); + return variant ? variant.path : null; +} + +// CLI usage +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const variants = discoverVariants(); + console.log('Discovered variants:'); + for (const variant of variants) { + console.log(` - ${variant.name} (${variant.dirName})`); + console.log(` Path: ${variant.path}`); + console.log(` Enterprise: ${variant.config.isEnterprise}`); + console.log(` Backend port: ${variant.config.ports.backend}`); + } +} diff --git a/frontend/tests/scripts/run-all-variants.mjs b/frontend/tests/scripts/run-all-variants.mjs new file mode 100644 index 000000000..28053c40d --- /dev/null +++ b/frontend/tests/scripts/run-all-variants.mjs @@ -0,0 +1,104 @@ +import { discoverVariants } from './discover-variants.mjs'; +import { spawn } from 'node:child_process'; +import { join } from 'node:path'; + +async function runVariant(variant, playwrightArgs = []) { + const configPath = join(variant.path, 'playwright.config.ts'); + + console.log(`\n${'='.repeat(60)}`); + console.log(`Running variant: ${variant.name} (${variant.config.displayName})`); + console.log(`${'='.repeat(60)}\n`); + + const args = ['playwright', 'test', '--config', configPath, ...playwrightArgs]; + + const child = spawn('npx', args, { + stdio: 'inherit', + cwd: variant.path, + env: { + ...process.env, + }, + }); + + return new Promise((resolve) => { + child.on('close', (code) => { + resolve({ variant: variant.name, code, skipped: false }); + }); + child.on('error', (error) => { + console.error(`Error running variant ${variant.name}:`, error.message); + resolve({ variant: variant.name, code: 1, skipped: false }); + }); + }); +} + +async function runAllVariants(playwrightArgs = []) { + // Get all variants including unrunnable ones for display + const allVariants = discoverVariants({ includeUnrunnable: true }); + + if (allVariants.length === 0) { + console.error('No variants found. Make sure test-variant-* directories exist with config/variant.json'); + process.exit(1); + } + + const runnableVariants = allVariants.filter((v) => v.canRun); + const skippedVariants = allVariants.filter((v) => !v.canRun); + + console.log(`Found ${allVariants.length} variant(s): ${allVariants.map((v) => v.name).join(', ')}`); + + if (skippedVariants.length > 0) { + console.log(`\nSkipping ${skippedVariants.length} variant(s) due to missing requirements:`); + for (const variant of skippedVariants) { + console.log(` - ${variant.name}: ${variant.skipReason}`); + } + } + + if (runnableVariants.length === 0) { + console.error('\nNo runnable variants found'); + process.exit(1); + } + + console.log(`\nRunning ${runnableVariants.length} variant(s)...`); + + const results = []; + + for (const variant of runnableVariants) { + const result = await runVariant(variant, playwrightArgs); + results.push(result); + } + + // Add skipped variants to results + for (const variant of skippedVariants) { + results.push({ variant: variant.name, code: 0, skipped: true, skipReason: variant.skipReason }); + } + + // Summary + console.log(`\n${'='.repeat(60)}`); + console.log('Summary'); + console.log(`${'='.repeat(60)}`); + + let hasFailures = false; + for (const result of results) { + if (result.skipped) { + console.log(` \u23ED ${result.variant}: SKIPPED`); + } else { + const status = result.code === 0 ? 'PASSED' : 'FAILED'; + const icon = result.code === 0 ? '\u2714' : '\u2718'; + console.log(` ${icon} ${result.variant}: ${status}`); + if (result.code !== 0) { + hasFailures = true; + } + } + } + + console.log(''); + + if (hasFailures) { + console.error('Some variants failed'); + process.exit(1); + } + + console.log('All runnable variants passed'); +} + +// CLI +const args = process.argv.slice(2); +runAllVariants(args); diff --git a/frontend/tests/scripts/run-variant.mjs b/frontend/tests/scripts/run-variant.mjs new file mode 100644 index 000000000..74b6d257e --- /dev/null +++ b/frontend/tests/scripts/run-variant.mjs @@ -0,0 +1,83 @@ +import { discoverVariants, getVariant } from './discover-variants.mjs'; +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testsDir = resolve(__dirname, '..'); + +function printUsage() { + console.log('Usage: bun run e2e-test:variant [playwright-options]'); + console.log(''); + console.log('Available variants:'); + const variants = discoverVariants(); + for (const variant of variants) { + console.log(` - ${variant.name}`); + } + console.log(''); + console.log('Examples:'); + console.log(' bun run e2e-test:variant console'); + console.log(' bun run e2e-test:variant console-enterprise --ui'); + console.log(' bun run e2e-test:variant console --headed'); +} + +async function runVariant(variantName, playwrightArgs = []) { + const variant = getVariant(variantName); + + if (!variant) { + console.error(`Error: Variant "${variantName}" not found.`); + console.log(''); + printUsage(); + process.exit(1); + } + + const configPath = join(variant.path, 'playwright.config.ts'); + + if (!existsSync(configPath)) { + console.error(`Error: Playwright config not found at ${configPath}`); + process.exit(1); + } + + console.log(`Running variant: ${variant.name} (${variant.config.displayName})`); + console.log(`Config: ${configPath}`); + console.log(''); + + const args = ['playwright', 'test', '--config', configPath, ...playwrightArgs]; + + const child = spawn('npx', args, { + stdio: 'inherit', + cwd: testsDir, + env: { + ...process.env, + }, + }); + + return new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) { + resolve(code); + } else { + reject(new Error(`Playwright exited with code ${code}`)); + } + }); + child.on('error', reject); + }); +} + +// CLI +const args = process.argv.slice(2); + +if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + printUsage(); + process.exit(0); +} + +const variantName = args[0]; +const playwrightArgs = args.slice(1); + +runVariant(variantName, playwrightArgs).catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/frontend/tests/config/Dockerfile.backend b/frontend/tests/shared/Dockerfile.backend similarity index 100% rename from frontend/tests/config/Dockerfile.backend rename to frontend/tests/shared/Dockerfile.backend diff --git a/frontend/tests/config/conf/.bootstrap-dest.yaml b/frontend/tests/shared/conf/.bootstrap-dest.yaml similarity index 100% rename from frontend/tests/config/conf/.bootstrap-dest.yaml rename to frontend/tests/shared/conf/.bootstrap-dest.yaml diff --git a/frontend/tests/config/conf/.bootstrap.yaml b/frontend/tests/shared/conf/.bootstrap.yaml similarity index 100% rename from frontend/tests/config/conf/.bootstrap.yaml rename to frontend/tests/shared/conf/.bootstrap.yaml diff --git a/frontend/tests/connector.utils.ts b/frontend/tests/shared/connector.utils.ts similarity index 97% rename from frontend/tests/connector.utils.ts rename to frontend/tests/shared/connector.utils.ts index f61a277d7..238f98780 100644 --- a/frontend/tests/connector.utils.ts +++ b/frontend/tests/shared/connector.utils.ts @@ -1,6 +1,6 @@ import { expect, type Page, test } from '@playwright/test'; -import { ACCESS_KEY, S3_BUCKET_NAME, SECRET_ACCESS_KEY } from './console/connectors/connector.spec'; +import { ACCESS_KEY, S3_BUCKET_NAME, SECRET_ACCESS_KEY } from '../test-variant-console/connectors/connector.spec'; export const createConnector = async ( page: Page, diff --git a/frontend/tests/config/console.dest.config.yaml b/frontend/tests/shared/console.dest.config.yaml similarity index 100% rename from frontend/tests/config/console.dest.config.yaml rename to frontend/tests/shared/console.dest.config.yaml diff --git a/frontend/tests/global-setup.mjs b/frontend/tests/shared/global-setup.mjs similarity index 71% rename from frontend/tests/global-setup.mjs rename to frontend/tests/shared/global-setup.mjs index 6287eea9b..73e50bc6d 100644 --- a/frontend/tests/global-setup.mjs +++ b/frontend/tests/shared/global-setup.mjs @@ -1,7 +1,7 @@ import { GenericContainer, Network, Wait } from 'testcontainers'; import { exec } from 'node:child_process'; -import { existsSync, realpathSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; @@ -13,26 +13,44 @@ const __dirname = dirname(__filename); // Regex for extracting container ID from error messages const CONTAINER_ID_REGEX = /container ([a-f0-9]+)/; -const getStateFile = (isEnterprise) => - resolve(__dirname, isEnterprise ? '.testcontainers-state-enterprise.json' : '.testcontainers-state.json'); +const getStateFile = (variantName) => resolve(__dirname, '..', `.testcontainers-state-${variantName}.json`); + +/** + * Load variant configuration from test-variant-{name}/config/variant.json + * @param {string} variantName - The variant name (e.g., "console", "console-enterprise") + * @returns {object} - The variant configuration including ports + */ +function loadVariantConfig(variantName) { + const variantDir = resolve(__dirname, '..', `test-variant-${variantName}`); + const configPath = join(variantDir, 'config', 'variant.json'); + + if (!existsSync(configPath)) { + throw new Error(`Variant config not found: ${configPath}`); + } + + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + return config; +} async function waitForPort(port, maxAttempts = 30, delayMs = 1000) { for (let i = 0; i < maxAttempts; i++) { try { const { stdout } = await execAsync( - `curl -s -o /dev/null -w "%{http_code}" http://localhost:${port}/ || echo "0"` + `curl -s -m 5 -o /dev/null -w "%{http_code}" http://localhost:${port}/ || echo "0"` ); const statusCode = Number.parseInt(stdout.trim(), 10); if (statusCode > 0 && statusCode < 500) { return true; } - if ((i + 1) % 10 === 0) { - console.log(` Still waiting... (attempt ${i + 1}/${maxAttempts})`); + if ((i + 1) % 5 === 0) { + console.log( + ` Still waiting for port ${port}... (attempt ${i + 1}/${maxAttempts}, last status: ${statusCode})` + ); } - } catch (_error) { + } catch (error) { // Port not ready yet - if ((i + 1) % 10 === 0) { - console.log(` Still waiting... (attempt ${i + 1}/${maxAttempts})`); + if ((i + 1) % 5 === 0) { + console.log(` Still waiting for port ${port}... (attempt ${i + 1}/${maxAttempts}, error: ${error.message})`); } } await new Promise((resolve) => setTimeout(resolve, delayMs)); @@ -48,16 +66,16 @@ async function setupDockerNetwork(state) { return network; } -async function startRedpandaContainer(network, state) { +async function startRedpandaContainer(network, state, ports) { console.log('Starting Redpanda container...'); const redpanda = await new GenericContainer('redpandadata/redpanda:v25.3.2') .withNetwork(network) .withNetworkAliases('redpanda') .withExposedPorts( - { container: 19_092, host: 19_092 }, - { container: 18_081, host: 18_081 }, - { container: 18_082, host: 18_082 }, - { container: 9644, host: 19_644 } + { container: 19_092, host: ports.redpandaKafka }, + { container: 18_081, host: ports.redpandaSchemaRegistry }, + { container: 18_082, host: ports.redpandaPandaproxy }, + { container: 9644, host: ports.redpandaAdmin } ) .withCommand([ 'redpanda', @@ -73,7 +91,7 @@ async function startRedpandaContainer(network, state) { '--pandaproxy-addr', 'internal://0.0.0.0:8082,external://0.0.0.0:18082', '--advertise-pandaproxy-addr', - 'internal://redpanda:8082,external://localhost:18082', + `internal://redpanda:8082,external://localhost:${ports.redpandaPandaproxy}`, '--schema-registry-addr', 'internal://0.0.0.0:8081,external://0.0.0.0:18081', '--rpc-addr', @@ -86,7 +104,7 @@ async function startRedpandaContainer(network, state) { }) .withBindMounts([ { - source: resolve(__dirname, 'config/conf/.bootstrap.yaml'), + source: resolve(__dirname, 'conf/.bootstrap.yaml'), target: '/etc/redpanda/.bootstrap.yaml', }, ]) @@ -106,18 +124,18 @@ async function startRedpandaContainer(network, state) { console.log(`āœ“ Redpanda container started: ${state.redpandaId}`); } -async function verifyRedpandaServices(state) { +async function verifyRedpandaServices(state, ports) { // Give Redpanda a moment to finish internal initialization console.log('Waiting for Redpanda services to initialize...'); await new Promise((resolve) => setTimeout(resolve, 5000)); // Check services are ready - console.log('Checking if Admin API is ready (port 19644)...'); - await waitForPort(19_644, 60, 2000); + console.log(`Checking if Admin API is ready (port ${ports.redpandaAdmin})...`); + await waitForPort(ports.redpandaAdmin, 60, 2000); console.log('āœ“ Admin API is ready'); - console.log('Checking if Schema Registry is ready (port 18081)...'); - await waitForPort(18_081, 60, 2000); + console.log(`Checking if Schema Registry is ready (port ${ports.redpandaSchemaRegistry})...`); + await waitForPort(ports.redpandaSchemaRegistry, 60, 2000); console.log('āœ“ Schema Registry is ready'); // Verify SASL authentication is working @@ -190,7 +208,7 @@ async function createKafkaConnectTopics(state) { } } -async function startKafkaConnect(network, state) { +async function startKafkaConnect(network, state, ports) { console.log('Starting Kafka Connect container...'); const connectConfig = ` key.converter=org.apache.kafka.connect.converters.ByteArrayConverter @@ -226,7 +244,7 @@ topic.creation.enable=false .withPlatform('linux/amd64') .withNetwork(network) .withNetworkAliases('connect') - .withExposedPorts({ container: 8083, host: 18_083 }) + .withExposedPorts({ container: 8083, host: ports.kafkaConnect }) .withEnvironment({ CONNECT_CONFIGURATION: connectConfig, CONNECT_BOOTSTRAP_SERVERS: 'redpanda:9092', @@ -245,7 +263,7 @@ topic.creation.enable=false // Verify it's responding console.log('Verifying Kafka Connect API...'); - await waitForPort(18_083, 60, 2000); + await waitForPort(ports.kafkaConnect, 60, 2000); console.log('āœ“ Kafka Connect API ready'); } catch (error) { console.log('⚠ Kafka Connect failed to start (connector tests may fail)'); @@ -270,8 +288,8 @@ topic.creation.enable=false } } -async function buildBackendImage(isEnterprise, devMode = false) { - console.log(`Building backend Docker image ${isEnterprise ? '(Enterprise)' : '(OSS)'}${devMode ? ' [DEV MODE]' : ''}...`); +async function buildBackendImage(isEnterprise) { + console.log(`Building backend Docker image ${isEnterprise ? '(Enterprise)' : '(OSS)'}...`); let backendDir; if (isEnterprise) { @@ -281,10 +299,10 @@ async function buildBackendImage(isEnterprise, devMode = false) { console.log(`Using ENTERPRISE_BACKEND_DIR from environment: ${backendDir}`); } else { // Default to relative path - backendDir = resolve(__dirname, '../../../console-enterprise/backend'); + backendDir = resolve(__dirname, '../../../../console-enterprise/backend'); } } else { - backendDir = resolve(__dirname, '../../backend'); + backendDir = resolve(__dirname, '../../../backend'); } // Resolve symlinks to real path (needed for Docker build context) @@ -307,38 +325,29 @@ async function buildBackendImage(isEnterprise, devMode = false) { ); } - embedDir = join(backendDir, 'pkg/embed/frontend'); + const frontendBuildDir = resolve(__dirname, '../../build'); - if (devMode) { - // In dev mode, create minimal placeholder (backend won't serve frontend) - console.log('Dev mode: Creating minimal frontend placeholder...'); - await execAsync(`mkdir -p "${embedDir}"`); - await execAsync(`echo "" > "${embedDir}/index.html"`); - console.log('āœ“ Placeholder created'); - } else { - // Normal mode: copy full frontend build - const frontendBuildDir = resolve(__dirname, '../build'); + // Check if frontend build exists + if (!existsSync(frontendBuildDir)) { + throw new Error( + `Frontend build directory not found: ${frontendBuildDir}\nRun "bun run build" before running E2E tests.` + ); + } - // Check if frontend build exists - if (!existsSync(frontendBuildDir)) { - throw new Error( - `Frontend build directory not found: ${frontendBuildDir}\nRun "bun run build" before running E2E tests.` - ); - } + embedDir = join(backendDir, 'pkg/embed/frontend'); - console.log(`Copying frontend assets to ${isEnterprise ? 'enterprise' : 'OSS'} backend...`); - console.log(` From: ${frontendBuildDir}`); - console.log(` To: ${embedDir}`); + console.log(`Copying frontend assets to ${isEnterprise ? 'enterprise' : 'OSS'} backend...`); + console.log(` From: ${frontendBuildDir}`); + console.log(` To: ${embedDir}`); - // Copy all files from build/ to pkg/embed/frontend/ - await execAsync(`cp -r "${frontendBuildDir}"/* "${embedDir}"/`); - console.log('āœ“ Frontend assets copied'); - } + // Copy all files from build/ to pkg/embed/frontend/ + await execAsync(`cp -r "${frontendBuildDir}"/* "${embedDir}"/`); + console.log('āœ“ Frontend assets copied'); // Build Docker image using testcontainers // Docker doesn't allow Dockerfiles to reference files outside build context, // so we temporarily copy the Dockerfile into the build context - const dockerfilePath = resolve(__dirname, 'config/Dockerfile.backend'); + const dockerfilePath = resolve(__dirname, 'Dockerfile.backend'); const tempDockerfile = join(backendDir, '.dockerfile.e2e.tmp'); console.log('Building Docker image with testcontainers...'); @@ -369,18 +378,12 @@ async function buildBackendImage(isEnterprise, devMode = false) { } // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: (21) nested test environment setup with multiple configuration checks -async function startBackendServer(network, isEnterprise, imageTag, state, devMode = false) { - console.log(`Starting backend server container${devMode ? ' [DEV MODE]' : ''}...`); +async function startBackendServer(network, isEnterprise, imageTag, state, variantName, configFile, ports) { + console.log('Starting backend server container...'); console.log(`Image tag: ${imageTag}`); console.log(`Enterprise mode: ${isEnterprise}`); - const backendConfigPath = devMode - ? (isEnterprise - ? resolve(__dirname, 'config/console.enterprise.dev.config.yaml') - : resolve(__dirname, 'config/console.dev.config.yaml')) - : (isEnterprise - ? resolve(__dirname, 'config/console.enterprise.config.yaml') - : resolve(__dirname, 'config/console.config.yaml')); + const backendConfigPath = resolve(__dirname, '..', `test-variant-${variantName}`, 'config', configFile); console.log(`Backend config path: ${backendConfigPath}`); @@ -411,9 +414,9 @@ async function startBackendServer(network, isEnterprise, imageTag, state, devMod // Default to relative path based on backend directory const backendDir = process.env.ENTERPRISE_BACKEND_DIR ? resolve(process.env.ENTERPRISE_BACKEND_DIR) - : resolve(__dirname, '../../../console-enterprise/backend'); + : resolve(__dirname, '../../../../console-enterprise/backend'); - const defaultLicensePath = resolve(backendDir, '../configs/shared/redpanda.license'); + const defaultLicensePath = resolve(backendDir, '../frontend/tests/config/redpanda.license'); licensePath = process.env.REDPANDA_LICENSE_PATH || defaultLicensePath; console.log(`Enterprise license path: ${licensePath}`); @@ -438,23 +441,19 @@ async function startBackendServer(network, isEnterprise, imageTag, state, devMod let backend; let containerId; try { - // In dev mode, expose backend on port 9090 (FE dev server proxies to it) - // In normal mode, backend serves FE on port 3000 - const hostPort = devMode ? 9090 : 3000; - console.log('Starting container...'); console.log('Configuration summary:'); console.log(` - Network: ${network.getId ? network.getId() : 'unknown'}`); console.log(' - Alias: console-backend'); - console.log(` - Port: 3000:${hostPort}`); + console.log(` - Port: ${ports.backend}:3000`); console.log(' - Command: --config.filepath=/etc/console/config.yaml'); - // Create container - don't use withWaitStrategy so we can see logs if it fails + // Create container without wait strategy first to get the ID immediately const container = new GenericContainer(imageTag) .withNetwork(network) .withNetworkAliases('console-backend') .withNetworkMode(network.getName()) - .withExposedPorts({ container: 3000, host: hostPort }) + .withExposedPorts({ container: 3000, host: ports.backend }) .withBindMounts(bindMounts) .withCommand(['--config.filepath=/etc/console/config.yaml']); @@ -524,8 +523,8 @@ async function startBackendServer(network, isEnterprise, imageTag, state, devMod } console.log(`Container created with ID: ${containerId}`); - console.log('Waiting 2 seconds for container to initialize...'); - await new Promise((resolve) => setTimeout(resolve, 2000)); + console.log('Waiting 5 seconds for container to fully initialize...'); + await new Promise((resolve) => setTimeout(resolve, 5000)); // Check if container is still running const { stdout: inspectOutput } = await execAsync(`docker inspect ${containerId} --format='{{.State.Status}}'`); @@ -557,9 +556,10 @@ async function startBackendServer(network, isEnterprise, imageTag, state, devMod } // Now wait for port to be ready - console.log(`Waiting for port ${hostPort} to be ready...`); - await waitForPort(hostPort, 60, 1000); - console.log(`āœ“ Port ${hostPort} is ready`); + console.log(`Waiting for port ${ports.backend} to be ready...`); + console.log('Testing connection with curl...'); + await waitForPort(ports.backend, 90, 2000); + console.log(`āœ“ Port ${ports.backend} is ready`); } catch (error) { console.error('Failed to start backend container:', error.message); @@ -585,16 +585,16 @@ async function startBackendServer(network, isEnterprise, imageTag, state, devMod } } -async function startDestinationRedpandaContainer(network, state) { +async function startDestinationRedpandaContainer(network, state, ports) { console.log('Starting destination Redpanda container for shadowlink...'); const destRedpanda = await new GenericContainer('redpandadata/redpanda:v25.3.2') .withNetwork(network) .withNetworkAliases('dest-cluster') .withExposedPorts( - { container: 19_093, host: 19_093 }, - { container: 18_091, host: 18_091 }, - { container: 18_092, host: 18_092 }, - { container: 9644, host: 19_645 } + { container: 19_093, host: ports.destRedpandaKafka }, + { container: 18_091, host: ports.destRedpandaSchemaRegistry }, + { container: 18_092, host: ports.destRedpandaPandaproxy }, + { container: 9644, host: ports.destRedpandaAdmin } ) .withCommand([ 'redpanda', @@ -610,7 +610,7 @@ async function startDestinationRedpandaContainer(network, state) { '--pandaproxy-addr', 'internal://0.0.0.0:8082,external://0.0.0.0:18092', '--advertise-pandaproxy-addr', - 'internal://dest-cluster:8082,external://localhost:18092', + `internal://dest-cluster:8082,external://localhost:${ports.destRedpandaPandaproxy}`, '--schema-registry-addr', 'internal://0.0.0.0:8081,external://0.0.0.0:18091', '--rpc-addr', @@ -623,7 +623,7 @@ async function startDestinationRedpandaContainer(network, state) { }) .withBindMounts([ { - source: resolve(__dirname, 'config/conf/.bootstrap-dest.yaml'), + source: resolve(__dirname, 'conf/.bootstrap-dest.yaml'), target: '/etc/redpanda/.bootstrap.yaml', }, ]) @@ -641,18 +641,40 @@ async function startDestinationRedpandaContainer(network, state) { state.destRedpandaId = destRedpanda.getId(); state.destRedpandaContainer = destRedpanda; console.log(`āœ“ Destination Redpanda container started: ${state.destRedpandaId}`); + + // Debug: Check port mappings + try { + const { stdout: portOutput } = await execAsync(`docker port ${state.destRedpandaId}`); + console.log('Destination container port mappings:'); + console.log(portOutput); + } catch (e) { + console.log('Could not get port mappings:', e.message); + } } -async function verifyDestinationRedpandaServices(state) { +async function verifyDestinationRedpandaServices(state, ports) { console.log('Waiting for destination Redpanda services...'); await new Promise((resolve) => setTimeout(resolve, 5000)); - console.log('Checking destination Admin API (port 19645)...'); - await waitForPort(19_645, 60, 2000); + // Debug: Check if container is still running + try { + const { stdout: status } = await execAsync(`docker inspect ${state.destRedpandaId} --format='{{.State.Status}}'`); + console.log(`Destination container status: ${status.trim()}`); + if (status.trim() !== 'running') { + const { stdout: logs } = await execAsync(`docker logs ${state.destRedpandaId} 2>&1 | tail -30`); + console.log('Destination container logs:'); + console.log(logs); + } + } catch (e) { + console.log('Could not check container status:', e.message); + } + + console.log(`Checking destination Admin API (port ${ports.destRedpandaAdmin})...`); + await waitForPort(ports.destRedpandaAdmin, 60, 2000); console.log('āœ“ Destination Admin API ready'); - console.log('Checking destination Schema Registry (port 18091)...'); - await waitForPort(18_091, 60, 2000); + console.log(`Checking destination Schema Registry (port ${ports.destRedpandaSchemaRegistry})...`); + await waitForPort(ports.destRedpandaSchemaRegistry, 60, 2000); console.log('āœ“ Destination Schema Registry ready'); } @@ -686,7 +708,10 @@ async function startBackendServerWithConfig( licensePath = `${tempDir}/redpanda.license`; writeFileSync(licensePath, process.env.ENTERPRISE_LICENSE_CONTENT); } else { - const defaultLicensePath = resolve(__dirname, '../../../console-enterprise/configs/shared/redpanda.license'); + const defaultLicensePath = resolve( + __dirname, + '../../../../console-enterprise/frontend/tests/config/redpanda.license' + ); licensePath = process.env.REDPANDA_LICENSE_PATH || defaultLicensePath; if (!fs.existsSync(licensePath)) { @@ -702,67 +727,31 @@ async function startBackendServerWithConfig( } let containerId; - let backend; - - console.log(`Attempting to start container on port ${externalPort}...`); - console.log('Bind mounts:', JSON.stringify(bindMounts, null, 2)); - try { - console.log('Calling GenericContainer.start()...'); - const containerBuilder = new GenericContainer(imageTag) + const backend = await new GenericContainer(imageTag) .withNetwork(network) .withNetworkAliases(networkAlias) .withExposedPorts({ container: 3000, host: externalPort }) .withBindMounts(bindMounts) .withCommand(['--config.filepath=/etc/console/config.yaml']) - .withLogConsumer((stream) => { - stream.on('data', (line) => console.log(`[CONTAINER LOG] ${line}`)); - stream.on('err', (line) => console.error(`[CONTAINER ERR] ${line}`)); - stream.on('end', () => console.log('[CONTAINER] Stream ended')); - }); - - // In CI, start container with logs streaming to catch failures - if (process.env.CI) { - console.log('CI detected - container logs will be streamed in real-time'); - } - - backend = await containerBuilder.start(); - console.log('GenericContainer.start() completed successfully'); + .start(); containerId = backend.getId(); state.backendId = containerId; state.backendContainer = backend; - console.log(`āœ“ Container started with ID: ${containerId}`); - - } catch (startError) { - console.error(`Error during container.start() on port ${externalPort}:`, startError.message); - // Logs are already captured by withLogConsumer above - throw startError; - } - try { await new Promise((resolve) => setTimeout(resolve, 2000)); // Check if container is still running const { stdout: status } = await execAsync(`docker inspect ${containerId} --format='{{.State.Status}}'`); if (status.trim() !== 'running') { - console.error(`Container ${containerId} is not running (status: ${status.trim()})`); const { stdout: logs } = await execAsync(`docker logs ${containerId} 2>&1`); const { stdout: exitCode } = await execAsync(`docker inspect ${containerId} --format='{{.State.ExitCode}}'`); - console.error(`Exit code: ${exitCode.trim()}`); - console.error('Container logs:'); + console.error(`Container ${containerId} stopped (exit ${exitCode.trim()}):`); console.error(logs); throw new Error(`Container stopped immediately with status: ${status.trim()}`); } - console.log(`āœ“ Container ${containerId} is running`); - - // Show initial logs - console.log('Initial container logs (last 30 lines):'); - const { stdout: logs } = await execAsync(`docker logs ${containerId} 2>&1 | tail -30`); - console.log(logs || '(no logs yet)'); - - console.log(`Waiting for port ${externalPort} to be ready...`); await waitForPort(externalPort, 60, 1000); console.log(`āœ“ Backend ready at http://localhost:${externalPort}`); } catch (error) { @@ -770,14 +759,11 @@ async function startBackendServerWithConfig( if (containerId) { try { - console.log('Fetching full diagnostics...'); const { stdout: logs } = await execAsync(`docker logs ${containerId} 2>&1`); const { stdout: inspect } = await execAsync(`docker inspect ${containerId}`); const inspectJson = JSON.parse(inspect); - console.error('=== FULL CONTAINER LOGS ==='); - console.error(logs); - console.error('=== CONTAINER STATE ==='); - console.error(JSON.stringify(inspectJson[0].State, null, 2)); + console.error('Container state:', JSON.stringify(inspectJson[0].State, null, 2)); + console.error('Container logs:', logs); } catch (logError) { console.error('Could not fetch container diagnostics:', logError.message); } @@ -787,48 +773,6 @@ async function startBackendServerWithConfig( } } -async function startSourceBackendServer(network, isEnterprise, imageTag, state) { - console.log('Starting source backend server container (port 3001)...'); - - // Use existing console.enterprise.config.yaml for source cluster - const sourceBackendConfigPath = isEnterprise - ? resolve(__dirname, 'config/console.enterprise.config.yaml') - : resolve(__dirname, 'config/console.config.yaml'); - - const bindMounts = [ - { - source: sourceBackendConfigPath, - target: '/etc/console/config.yaml', - mode: 'ro', - }, - ]; - - if (isEnterprise) { - const defaultLicensePath = resolve(__dirname, '../../../console-enterprise/configs/shared/redpanda.license'); - const licensePath = process.env.REDPANDA_LICENSE_PATH || defaultLicensePath; - bindMounts.push({ - source: licensePath, - target: '/etc/console/redpanda.license', - mode: 'ro', - }); - } - - const sourceBackend = await new GenericContainer(imageTag) - .withNetwork(network) - .withNetworkAliases('console-backend-source') - .withExposedPorts({ container: 3000, host: 3001 }) // Map to 3001 externally - .withBindMounts(bindMounts) - .withCommand(['--config.filepath=/etc/console/config.yaml']) - .start(); - - state.sourceBackendId = sourceBackend.getId(); - state.sourceBackendContainer = sourceBackend; - - console.log('Waiting for source backend port 3001...'); - await waitForPort(3001, 60, 1000); - console.log('āœ“ Source backend ready at http://localhost:3001'); -} - async function cleanupOnFailure(state) { if (state.sourceBackendContainer) { console.log('Stopping source backend container using testcontainers API...'); @@ -877,24 +821,28 @@ async function cleanupOnFailure(state) { } export default async function globalSetup(config = {}) { + // Get variant configuration from metadata + const variantName = config?.metadata?.variantName ?? 'console'; + const configFile = config?.metadata?.configFile ?? 'console.config.yaml'; const isEnterprise = config?.metadata?.isEnterprise ?? false; const needsShadowlink = config?.metadata?.needsShadowlink ?? false; - const devMode = process.env.E2E_DEV_MODE === 'true'; + + // Load ports from variant's config/variant.json + const variantConfig = loadVariantConfig(variantName); + const ports = variantConfig.ports; console.log('\n\n========================================'); - console.log( - `šŸš€ GLOBAL SETUP STARTED ${isEnterprise ? '(ENTERPRISE MODE)' : '(OSS MODE)'}${needsShadowlink ? ' + SHADOWLINK' : ''}${devMode ? ' [DEV MODE]' : ''}` - ); + console.log(`šŸš€ GLOBAL SETUP: ${variantName}${needsShadowlink ? ' + SHADOWLINK' : ''}`); console.log('========================================\n'); + console.log('DEBUG - Config metadata:', { + variantName, + configFile, + isEnterprise, + needsShadowlink, + ports, + }); console.log('Starting testcontainers environment...'); - if (devMode) { - console.log('\n⚔ DEV MODE ENABLED'); - console.log(' - Backend will run on port 9090'); - console.log(' - Start FE dev server with: bun start'); - console.log(' - FE dev server will proxy to backend at localhost:9090\n'); - } - const state = { networkId: '', redpandaId: '', @@ -905,100 +853,89 @@ export default async function globalSetup(config = {}) { sourceBackendId: '', isEnterprise, needsShadowlink, - devMode, }; try { // Build backend Docker image - const imageTag = await buildBackendImage(isEnterprise, devMode); + const imageTag = await buildBackendImage(isEnterprise); // Setup Docker infrastructure const network = await setupDockerNetwork(state); // Start source cluster (existing cluster - has OwlShop data) - await startRedpandaContainer(network, state); - await verifyRedpandaServices(state); + await startRedpandaContainer(network, state, ports); + await verifyRedpandaServices(state, ports); await startOwlShop(network, state); await createKafkaConnectTopics(state); - // await startKafkaConnect(network, state); + // await startKafkaConnect(network, state, ports); // Start destination cluster for shadowlink if needed if (isEnterprise && needsShadowlink) { - await startDestinationRedpandaContainer(network, state); - await verifyDestinationRedpandaServices(state); + await startDestinationRedpandaContainer(network, state, ports); + await verifyDestinationRedpandaServices(state, ports); } console.log(''); console.log('=== Docker Environment Ready ==='); - console.log(' - Source Redpanda broker (external): localhost:19092'); + console.log(` - Source Redpanda broker (external): localhost:${ports.redpandaKafka}`); console.log(' - Source Redpanda broker (internal): redpanda:9092'); - console.log(' - Schema Registry: http://localhost:18081'); - console.log(' - Admin API: http://localhost:19644'); + console.log(` - Schema Registry: http://localhost:${ports.redpandaSchemaRegistry}`); + console.log(` - Admin API: http://localhost:${ports.redpandaAdmin}`); if (needsShadowlink) { - console.log(' - Destination Redpanda broker (external): localhost:19093'); + console.log(` - Destination Redpanda broker (external): localhost:${ports.destRedpandaKafka}`); console.log(' - Destination Redpanda broker (internal): dest-cluster:9092'); - console.log(' - Destination Schema Registry: http://localhost:18091'); - console.log(' - Destination Admin API: http://localhost:19645'); + console.log(` - Destination Schema Registry: http://localhost:${ports.destRedpandaSchemaRegistry}`); + console.log(` - Destination Admin API: http://localhost:${ports.destRedpandaAdmin}`); } - console.log(' - Kafka Connect: http://localhost:18083'); + console.log(` - Kafka Connect: http://localhost:${ports.kafkaConnect}`); console.log('================================\n'); // Start backend server(s) if (needsShadowlink) { - // For shadowlink tests: start source backend on port 3000 (existing data) - const sourceBackendConfigPath = isEnterprise - ? resolve(__dirname, 'config/console.enterprise.config.yaml') - : resolve(__dirname, 'config/console.config.yaml'); + // For shadowlink tests: start source backend on port ports.backend (existing data) + const sourceBackendConfigPath = resolve(__dirname, '..', `test-variant-${variantName}`, 'config', configFile); await startBackendServerWithConfig( network, isEnterprise, imageTag, state, sourceBackendConfigPath, - 3000, + ports.backend, 'console-backend' ); - // Start destination backend on port 3001 (where shadowlinks are created) - const destBackendConfigPath = resolve(__dirname, 'config/console.dest.config.yaml'); + // Start destination backend on port ports.backendDest (where shadowlinks are created) + const destBackendConfigPath = resolve(__dirname, 'console.dest.config.yaml'); await startBackendServerWithConfig( network, isEnterprise, imageTag, state, destBackendConfigPath, - 3001, + ports.backendDest, 'console-backend-dest' ); } else { // Normal setup - single backend on existing cluster - await startBackendServer(network, isEnterprise, imageTag, state, devMode); + await startBackendServer(network, isEnterprise, imageTag, state, variantName, configFile, ports); } // Wait for services to be ready - const backendPort = devMode ? 9090 : 3000; - console.log(`Waiting for backend to be ready on port ${backendPort}...`); - await waitForPort(backendPort, 60, 1000); + console.log('Waiting for backend to be ready...'); + await waitForPort(ports.backend, 60, 1000); console.log('Backend is ready'); - if (devMode) { - console.log('\n⚔ Backend is ready on port 9090'); - console.log('⚔ Now start FE dev server: bun start'); - console.log('⚔ Then run Playwright tests - they will use http://localhost:3000\n'); - } else { - console.log('Waiting for frontend to be ready...'); - await waitForPort(3000, 60, 1000); - } + console.log('Waiting for frontend to be ready...'); + await waitForPort(ports.backend, 60, 1000); // Give services extra time to stabilize in CI (especially shadowlink replication) - // Shadowlink needs time to establish connection between clusters and start replication if (isEnterprise && needsShadowlink && process.env.CI) { - console.log('CI detected: Giving services 30 seconds to stabilize (shadowlink replication)...'); - await new Promise((resolve) => setTimeout(resolve, 30_000)); + console.log('CI detected: Giving services 10 seconds to stabilize...'); + await new Promise((resolve) => setTimeout(resolve, 10_000)); console.log('āœ“ Stabilization period complete'); } - writeFileSync(getStateFile(isEnterprise), JSON.stringify(state, null, 2)); + writeFileSync(getStateFile(variantName), JSON.stringify(state, null, 2)); console.log('\nāœ… All services ready! Starting tests...\n'); } catch (error) { diff --git a/frontend/tests/global-teardown.mjs b/frontend/tests/shared/global-teardown.mjs similarity index 90% rename from frontend/tests/global-teardown.mjs rename to frontend/tests/shared/global-teardown.mjs index c3e3131a8..162c844a1 100644 --- a/frontend/tests/global-teardown.mjs +++ b/frontend/tests/shared/global-teardown.mjs @@ -8,14 +8,13 @@ const execAsync = promisify(exec); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const getStateFile = (isEnterprise) => - resolve(__dirname, isEnterprise ? '.testcontainers-state-enterprise.json' : '.testcontainers-state.json'); +const getStateFile = (variantName) => resolve(__dirname, '..', `.testcontainers-state-${variantName}.json`); export default async function globalTeardown(config = {}) { - const isEnterprise = config?.metadata?.isEnterprise ?? false; - const CONTAINER_STATE_FILE = getStateFile(isEnterprise); + const variantName = config?.metadata?.variantName ?? 'console'; + const CONTAINER_STATE_FILE = getStateFile(variantName); - console.log(`\nšŸ›‘ Stopping test environment ${isEnterprise ? '(ENTERPRISE MODE)' : '(OSS MODE)'}...`); + console.log(`\nšŸ›‘ TEARDOWN: ${variantName}...`); try { if (!fs.existsSync(CONTAINER_STATE_FILE)) { diff --git a/frontend/tests/schema.utils.ts b/frontend/tests/shared/schema.utils.ts similarity index 100% rename from frontend/tests/schema.utils.ts rename to frontend/tests/shared/schema.utils.ts diff --git a/frontend/tests/seed.spec.ts b/frontend/tests/shared/seed.spec.ts similarity index 100% rename from frontend/tests/seed.spec.ts rename to frontend/tests/shared/seed.spec.ts diff --git a/frontend/tests/console-enterprise/acl.spec.ts b/frontend/tests/test-variant-console-enterprise/acl.spec.ts similarity index 99% rename from frontend/tests/console-enterprise/acl.spec.ts rename to frontend/tests/test-variant-console-enterprise/acl.spec.ts index dd751ae1f..79bbb92bf 100644 --- a/frontend/tests/console-enterprise/acl.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/acl.spec.ts @@ -17,8 +17,8 @@ import { ResourceTypeTransactionalId, type Rule, } from '../../src/components/pages/acls/new-acl/acl.model'; -import { AclPage } from '../console/utils/acl-page'; -import { RolePage } from '../console/utils/role-page'; +import { AclPage } from '../test-variant-console/utils/acl-page'; +import { RolePage } from '../test-variant-console/utils/role-page'; /** * Generates a unique principal name for testing diff --git a/frontend/tests/auth.setup.ts b/frontend/tests/test-variant-console-enterprise/auth.setup.ts similarity index 100% rename from frontend/tests/auth.setup.ts rename to frontend/tests/test-variant-console-enterprise/auth.setup.ts diff --git a/frontend/tests/config/console.enterprise.config.yaml b/frontend/tests/test-variant-console-enterprise/config/console.config.yaml similarity index 100% rename from frontend/tests/config/console.enterprise.config.yaml rename to frontend/tests/test-variant-console-enterprise/config/console.config.yaml diff --git a/frontend/tests/test-variant-console-enterprise/config/variant.json b/frontend/tests/test-variant-console-enterprise/config/variant.json new file mode 100644 index 000000000..654720abd --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise/config/variant.json @@ -0,0 +1,21 @@ +{ + "name": "console-enterprise", + "displayName": "Console Enterprise", + "isEnterprise": true, + "needsShadowlink": true, + "needsAuth": true, + "requiresLicense": true, + "ports": { + "backend": 3100, + "backendDest": 3101, + "redpandaKafka": 19192, + "redpandaSchemaRegistry": 18181, + "redpandaAdmin": 19744, + "redpandaPandaproxy": 18182, + "kafkaConnect": 18183, + "destRedpandaKafka": 19193, + "destRedpandaSchemaRegistry": 18191, + "destRedpandaAdmin": 19745, + "destRedpandaPandaproxy": 18192 + } +} diff --git a/frontend/tests/console-enterprise/debug-bundle/debug-bundle.spec.ts b/frontend/tests/test-variant-console-enterprise/debug-bundle/debug-bundle.spec.ts similarity index 99% rename from frontend/tests/console-enterprise/debug-bundle/debug-bundle.spec.ts rename to frontend/tests/test-variant-console-enterprise/debug-bundle/debug-bundle.spec.ts index 6543796dc..cc745e0fc 100644 --- a/frontend/tests/console-enterprise/debug-bundle/debug-bundle.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/debug-bundle/debug-bundle.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { DebugBundlePage } from '../../console/utils/debug-bundle-page'; +import { DebugBundlePage } from '../../test-variant-console/utils/debug-bundle-page'; /** * Debug Bundle E2E Tests diff --git a/frontend/tests/test-variant-console-enterprise/fixtures.ts b/frontend/tests/test-variant-console-enterprise/fixtures.ts new file mode 100644 index 000000000..4834376c0 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise/fixtures.ts @@ -0,0 +1,17 @@ +import { test as base } from '@playwright/test'; + +// Extend test fixtures to include shadowBackendURL +type CustomFixtures = { + shadowBackendURL: string; +}; + +export const test = base.extend({ + shadowBackendURL: async ({}, use, testInfo) => { + // Get shadowBackendURL from the project's use options + const projectUse = testInfo.project.use as any; + const url = projectUse.shadowBackendURL || 'http://localhost:3001'; + await use(url); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/frontend/tests/console-enterprise/license.spec.ts b/frontend/tests/test-variant-console-enterprise/license.spec.ts similarity index 100% rename from frontend/tests/console-enterprise/license.spec.ts rename to frontend/tests/test-variant-console-enterprise/license.spec.ts diff --git a/frontend/tests/test-variant-console-enterprise/playwright.config.ts b/frontend/tests/test-variant-console-enterprise/playwright.config.ts new file mode 100644 index 000000000..858f3b758 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise/playwright.config.ts @@ -0,0 +1,94 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +dotenv.config(); + +// Configure reporters based on environment +const reporters = process.env.CI + ? [['github' as const], ['html' as const, { outputFolder: 'playwright-report' }]] + : [['list' as const], ['html' as const, { outputFolder: 'playwright-report' }]]; + +/** + * Playwright Test configuration for Enterprise (console-enterprise) variant + */ +const config = defineConfig({ + // Extended timeout for shadowlink tests + timeout: 120 * 1000, + + expect: { + timeout: 60 * 1000, + }, + + // Test directory specified in package.json script + testMatch: '**/*.spec.ts', + + /* Run tests in files in parallel */ + fullyParallel: !!process.env.CI, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + + /* Reduced workers for enterprise/shadowlink setup */ + workers: process.env.CI ? 4 : undefined, + + /* Reporter to use */ + reporter: reporters, + + /* Global setup and teardown */ + globalSetup: '../shared/global-setup.mjs', + globalTeardown: '../shared/global-teardown.mjs', + + /* Custom metadata for setup/teardown */ + metadata: { + variant: 'console-enterprise', + variantName: 'console-enterprise', + configFile: 'console.config.yaml', + isEnterprise: true, + needsShadowlink: true, + }, + + /* Shared settings for all projects */ + use: { + navigationTimeout: 30 * 1000, + actionTimeout: 30 * 1000, + viewport: { width: 1920, height: 1080 }, + headless: !!process.env.CI, + + /* Base URL uses enterprise backend port */ + baseURL: process.env.REACT_APP_ORIGIN ?? 'http://localhost:3100', + + /* Collect trace when retrying the failed test */ + trace: 'on-first-retry', + + /* Disable screenshots and videos in CI for better performance */ + screenshot: 'off', + video: 'off', + + /* Shadowlink destination backend URL (port 3101) */ + shadowBackendURL: 'http://localhost:3101', + } as any, + + /* Configure projects */ + projects: [ + // Enterprise: Authentication setup project + { + name: 'authenticate', + testMatch: '**/auth.setup.ts', + }, + // Enterprise: Main test project with auth state + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + permissions: ['clipboard-read', 'clipboard-write'], + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['authenticate'], + }, + ], +}); + +export default config; diff --git a/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts b/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts new file mode 100644 index 000000000..488d75652 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts @@ -0,0 +1,146 @@ +/** biome-ignore-all lint/performance/useTopLevelRegex: e2e test */ + +import { generateShadowlinkName, ShadowlinkPage } from '../../test-variant-console/utils/shadowlink-page'; +import { expect, test } from '../fixtures'; + +test.describe('Shadow Link E2E Tests', () => { + test.describe('Shadow Link Creation', () => { + test('should fill connection step successfully', async ({ page, shadowBackendURL }) => { + const shadowlinkPage = new ShadowlinkPage(page, shadowBackendURL); + const shadowlinkName = generateShadowlinkName(); + + await test.step('Navigate to create page', async () => { + await shadowlinkPage.gotoCreate(); + }); + + await test.step('Fill connection details with SCRAM', async () => { + await shadowlinkPage.fillConnectionStep({ + name: shadowlinkName, + bootstrapServers: 'redpanda:9092', // Source cluster (existing) + username: 'e2euser', + password: 'very-secret', + mechanism: 'SCRAM-SHA-256', + }); + }); + + await test.step('Verify we reached configuration step', async () => { + // After clicking Next, we should be on step 2 (Configuration) + await expect(page.getByRole('heading', { name: /configuration/i })).toBeVisible(); + }); + }); + + test('should create, update, failover, and delete shadowlink', async ({ page, shadowBackendURL }) => { + const shadowlinkPage = new ShadowlinkPage(page, shadowBackendURL); + const shadowlinkName = generateShadowlinkName(); + + await test.step('Create shadowlink with literal filter', async () => { + await shadowlinkPage.createShadowlink({ + name: shadowlinkName, + bootstrapServers: 'redpanda:9092', // Source cluster + username: 'e2euser', + password: 'very-secret', + topicFilters: [ + { + type: 'LITERAL', + filter: 'INCLUDE', + pattern: 'owlshop-orders', + }, + ], + }); + }); + + await test.step('Verify shadowlink appears on home page', async () => { + await shadowlinkPage.verifyOnHomePage(shadowlinkName); + }); + + await test.step('Verify topic synced on details page', async () => { + await shadowlinkPage.gotoDetails(shadowlinkName); + await shadowlinkPage.verifyTopicExists('owlshop-orders'); + }); + + await test.step('Update to prefix filter', async () => { + await shadowlinkPage.gotoEdit(shadowlinkName); + await shadowlinkPage.updateTopicFilters([ + { + type: 'PREFIX', + filter: 'INCLUDE', + pattern: 'owlshop-', + }, + ]); + }); + + await test.step('Failover single topic and wait for all topics', async () => { + await shadowlinkPage.gotoDetails(shadowlinkName); + // Failover single topic (will verify state change) + await shadowlinkPage.failoverTopic('owlshop-orders'); + }); + + await test.step('Failover all topics', async () => { + // Wait for all 7 topics to sync (uses metrics which is faster) + await shadowlinkPage.verifyMetrics({ + totalTopics: 7, + }); + await shadowlinkPage.performFailover(); + }); + + await test.step('Verify all topics failed over', async () => { + await shadowlinkPage.gotoDetails(shadowlinkName); + await shadowlinkPage.verifyMetrics({ + totalTopics: 7, + failedOverTopics: 7, + errorTopics: 0, + }); + }); + + await test.step('Delete shadowlink', async () => { + await shadowlinkPage.deleteShadowlink(); + }); + + await test.step('Verify shadowlink deleted', async () => { + await shadowlinkPage.verifyNotInList(shadowlinkName); + }); + }); + }); + + test.describe('Shadow Link Filter', () => { + test.skip(!!process.env.CI, 'Flaky in CI - timing issues with metrics loading'); + test('should create with exclude filter and verify it works', async ({ page, shadowBackendURL }) => { + const shadowlinkPage = new ShadowlinkPage(page, shadowBackendURL); + const shadowlinkName = generateShadowlinkName(); + + await test.step('Create with include-all and exclude addresses filter', async () => { + await shadowlinkPage.createShadowlink({ + name: shadowlinkName, + bootstrapServers: 'redpanda:9092', // Source cluster + username: 'e2euser', + password: 'very-secret', + topicFilters: [ + { + type: 'LITERAL', + filter: 'INCLUDE', + pattern: '*', + }, + { + type: 'LITERAL', + filter: 'EXCLUDE', + pattern: 'owlshop-addresses', + }, + ], + }); + }); + + await test.step('Wait for topics to sync (excluding addresses)', async () => { + await shadowlinkPage.gotoDetails(shadowlinkName); + // Wait for 9 topics (10 total - 1 excluded addresses) + await shadowlinkPage.verifyMetrics({ + totalTopics: 9, + }); + }); + + await test.step('Verify addresses topic is excluded', async () => { + // Should not see owlshop-addresses in the topics list + await expect(page.getByTestId('shadowlink-topic-owlshop-addresses')).not.toBeVisible(); + }); + }); + }); +}); diff --git a/frontend/tests/console-enterprise/users.spec.ts b/frontend/tests/test-variant-console-enterprise/users.spec.ts similarity index 95% rename from frontend/tests/console-enterprise/users.spec.ts rename to frontend/tests/test-variant-console-enterprise/users.spec.ts index 79cc374b0..fa40f86bb 100644 --- a/frontend/tests/console-enterprise/users.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/users.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { SecurityPage } from '../console/utils/security-page'; +import { SecurityPage } from '../test-variant-console/utils/security-page'; test.describe('Users', () => { test('should create an user, check that user exists, user can be deleted', async ({ page }) => { diff --git a/frontend/tests/console/acls/acl.spec.ts b/frontend/tests/test-variant-console/acls/acl.spec.ts similarity index 100% rename from frontend/tests/console/acls/acl.spec.ts rename to frontend/tests/test-variant-console/acls/acl.spec.ts diff --git a/frontend/tests/config/console.config.yaml b/frontend/tests/test-variant-console/config/console.config.yaml similarity index 100% rename from frontend/tests/config/console.config.yaml rename to frontend/tests/test-variant-console/config/console.config.yaml diff --git a/frontend/tests/test-variant-console/config/variant.json b/frontend/tests/test-variant-console/config/variant.json new file mode 100644 index 000000000..5f720bd8c --- /dev/null +++ b/frontend/tests/test-variant-console/config/variant.json @@ -0,0 +1,15 @@ +{ + "name": "console", + "displayName": "Console OSS", + "isEnterprise": false, + "needsShadowlink": false, + "needsAuth": false, + "ports": { + "backend": 3000, + "redpandaKafka": 19092, + "redpandaSchemaRegistry": 18081, + "redpandaAdmin": 19644, + "redpandaPandaproxy": 18082, + "kafkaConnect": 18083 + } +} diff --git a/frontend/tests/console/connectors/connector.spec.ts b/frontend/tests/test-variant-console/connectors/connector.spec.ts similarity index 93% rename from frontend/tests/console/connectors/connector.spec.ts rename to frontend/tests/test-variant-console/connectors/connector.spec.ts index 687edbbd5..0296754a2 100644 --- a/frontend/tests/console/connectors/connector.spec.ts +++ b/frontend/tests/test-variant-console/connectors/connector.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { randomUUID } from 'node:crypto'; -import { createConnector, deleteConnector } from '../../connector.utils'; +import { createConnector, deleteConnector } from '../../shared/connector.utils'; // biome-ignore lint/suspicious/noExportsInTest: ignore for this test export const ACCESS_KEY = 'accesskey'; diff --git a/frontend/playwright.config.ts b/frontend/tests/test-variant-console/playwright.config.ts similarity index 54% rename from frontend/playwright.config.ts rename to frontend/tests/test-variant-console/playwright.config.ts index 447a7a944..e3dea7659 100644 --- a/frontend/playwright.config.ts +++ b/frontend/tests/test-variant-console/playwright.config.ts @@ -1,48 +1,63 @@ import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); dotenv.config(); +// Configure reporters based on environment +const reporters = process.env.CI + ? [['github' as const], ['html' as const, { outputFolder: 'playwright-report' }]] + : [['list' as const], ['html' as const, { outputFolder: 'playwright-report' }]]; + /** - * See https://playwright.dev/docs/test-configuration. + * Playwright Test configuration for OSS (console) variant */ -export default defineConfig({ +const config = defineConfig({ expect: { timeout: 60 * 1000, }, - testDir: './tests', + + // Test directory specified in package.json script + testMatch: '**/*.spec.ts', + /* Run tests in files in parallel */ fullyParallel: !!process.env.CI, + /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, + /* Retry on CI only */ retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ + + /* Number of parallel workers */ workers: process.env.CI ? 4 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + + /* Reporter to use */ + reporter: reporters, + /* Global setup and teardown */ - globalSetup: './tests/global-setup.mjs', - globalTeardown: './tests/global-teardown.mjs', + globalSetup: '../shared/global-setup.mjs', + globalTeardown: '../shared/global-teardown.mjs', + /* Custom metadata for setup/teardown */ metadata: { + variant: 'console', + variantName: 'console', + configFile: 'console.config.yaml', isEnterprise: false, + needsShadowlink: false, }, - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + + /* Shared settings for all projects */ use: { navigationTimeout: 30 * 1000, actionTimeout: 30 * 1000, viewport: { width: 1920, height: 1080 }, headless: !!process.env.CI, - /* Base URL to use in actions like `await page.goto('/')`. */ + + /* Base URL uses variant-specific backend port */ baseURL: process.env.REACT_APP_ORIGIN ?? 'http://localhost:3000', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + /* Collect trace when retrying the failed test */ trace: 'retain-on-failure', /* Capture screenshot on failure */ screenshot: 'only-on-failure', @@ -50,17 +65,17 @@ export default defineConfig({ video: 'retain-on-failure', }, - /* Configure projects for major browsers */ + /* Configure projects */ projects: [ + // OSS: Single project without authentication { name: 'chromium', use: { ...devices['Desktop Chrome'], - // Grant clipboard permissions for tests permissions: ['clipboard-read', 'clipboard-write'], - // Use prepared auth state. - // storageState: 'playwright/.auth/user.json', }, }, ], }); + +export default config; diff --git a/frontend/tests/console/schemas/schema.spec.ts b/frontend/tests/test-variant-console/schemas/schema.spec.ts similarity index 100% rename from frontend/tests/console/schemas/schema.spec.ts rename to frontend/tests/test-variant-console/schemas/schema.spec.ts diff --git a/frontend/tests/console/topics/topic-create-defaults.spec.ts b/frontend/tests/test-variant-console/topics/topic-create-defaults.spec.ts similarity index 99% rename from frontend/tests/console/topics/topic-create-defaults.spec.ts rename to frontend/tests/test-variant-console/topics/topic-create-defaults.spec.ts index a413eac0d..b510e7b66 100644 --- a/frontend/tests/console/topics/topic-create-defaults.spec.ts +++ b/frontend/tests/test-variant-console/topics/topic-create-defaults.spec.ts @@ -12,7 +12,7 @@ const mockClusterResponse = { brokers: [ { brokerId: 0, - logDirSize: 283431018, + logDirSize: 283_431_018, address: 'redpanda', rack: null, config: { diff --git a/frontend/tests/console/topics/topic-creation.spec.ts b/frontend/tests/test-variant-console/topics/topic-creation.spec.ts similarity index 100% rename from frontend/tests/console/topics/topic-creation.spec.ts rename to frontend/tests/test-variant-console/topics/topic-creation.spec.ts diff --git a/frontend/tests/console/topics/topic-list.spec.ts b/frontend/tests/test-variant-console/topics/topic-list.spec.ts similarity index 100% rename from frontend/tests/console/topics/topic-list.spec.ts rename to frontend/tests/test-variant-console/topics/topic-list.spec.ts diff --git a/frontend/tests/console/topics/topic-messages-actions.spec.ts b/frontend/tests/test-variant-console/topics/topic-messages-actions.spec.ts similarity index 100% rename from frontend/tests/console/topics/topic-messages-actions.spec.ts rename to frontend/tests/test-variant-console/topics/topic-messages-actions.spec.ts diff --git a/frontend/tests/console/topics/topic-messages-filtering.spec.ts b/frontend/tests/test-variant-console/topics/topic-messages-filtering.spec.ts similarity index 100% rename from frontend/tests/console/topics/topic-messages-filtering.spec.ts rename to frontend/tests/test-variant-console/topics/topic-messages-filtering.spec.ts diff --git a/frontend/tests/console/topics/topic-messages-production.spec.ts b/frontend/tests/test-variant-console/topics/topic-messages-production.spec.ts similarity index 100% rename from frontend/tests/console/topics/topic-messages-production.spec.ts rename to frontend/tests/test-variant-console/topics/topic-messages-production.spec.ts diff --git a/frontend/tests/console/topics/topic-navigation.spec.ts b/frontend/tests/test-variant-console/topics/topic-navigation.spec.ts similarity index 100% rename from frontend/tests/console/topics/topic-navigation.spec.ts rename to frontend/tests/test-variant-console/topics/topic-navigation.spec.ts diff --git a/frontend/tests/console/transforms/transforms.spec.ts b/frontend/tests/test-variant-console/transforms/transforms.spec.ts similarity index 100% rename from frontend/tests/console/transforms/transforms.spec.ts rename to frontend/tests/test-variant-console/transforms/transforms.spec.ts diff --git a/frontend/tests/console/utils/acl-page.ts b/frontend/tests/test-variant-console/utils/acl-page.ts similarity index 100% rename from frontend/tests/console/utils/acl-page.ts rename to frontend/tests/test-variant-console/utils/acl-page.ts diff --git a/frontend/tests/console/utils/debug-bundle-page.ts b/frontend/tests/test-variant-console/utils/debug-bundle-page.ts similarity index 100% rename from frontend/tests/console/utils/debug-bundle-page.ts rename to frontend/tests/test-variant-console/utils/debug-bundle-page.ts diff --git a/frontend/tests/console/utils/role-page.ts b/frontend/tests/test-variant-console/utils/role-page.ts similarity index 100% rename from frontend/tests/console/utils/role-page.ts rename to frontend/tests/test-variant-console/utils/role-page.ts diff --git a/frontend/tests/console/utils/schema-page.ts b/frontend/tests/test-variant-console/utils/schema-page.ts similarity index 100% rename from frontend/tests/console/utils/schema-page.ts rename to frontend/tests/test-variant-console/utils/schema-page.ts diff --git a/frontend/tests/console/utils/security-page.ts b/frontend/tests/test-variant-console/utils/security-page.ts similarity index 100% rename from frontend/tests/console/utils/security-page.ts rename to frontend/tests/test-variant-console/utils/security-page.ts diff --git a/frontend/tests/test-variant-console/utils/shadowlink-page.ts b/frontend/tests/test-variant-console/utils/shadowlink-page.ts new file mode 100644 index 000000000..d9cf6703d --- /dev/null +++ b/frontend/tests/test-variant-console/utils/shadowlink-page.ts @@ -0,0 +1,637 @@ +/** biome-ignore-all lint/performance/useTopLevelRegex: e2e test */ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +/** + * Helper to generate unique shadowlink names + */ +export function generateShadowlinkName() { + const timestamp = Date.now().toString().slice(-8); + const random = Math.random().toString(36).substring(2, 6); + return `e2e-shadowlink-${timestamp}-${random}`; +} + +/** + * Page Object Model for Shadow Link pages + * Handles shadowlink list, create, edit, details, and verification + * + * Shadowlinks are created on the DESTINATION cluster console + * to pull data FROM the source cluster. + * Uses shadowBackendURL from Playwright config if available. + */ +export class ShadowlinkPage { + readonly page: Page; + readonly shadowBackendURL: string; + + constructor(page: Page, shadowBackendURL?: string) { + this.page = page; + // shadowBackendURL should be passed from the test using the fixture + // Falls back to 3001 if not provided + this.shadowBackendURL = shadowBackendURL ?? 'http://localhost:3001'; + } + + /** + * Navigation methods + */ + async goto() { + await this.page.goto(`${this.shadowBackendURL}/shadowlinks`); + await expect(this.page.getByRole('heading', { name: /shadow links/i })).toBeVisible({ timeout: 10_000 }); + } + + async gotoCreate() { + await this.page.goto(`${this.shadowBackendURL}/shadowlinks/create`); + await expect(this.page.getByRole('heading', { name: /create shadow link/i })).toBeVisible({ timeout: 10_000 }); + } + + async gotoDetails(name: string) { + await this.page.goto(`${this.shadowBackendURL}/shadowlinks/${encodeURIComponent(name)}`); + await expect(this.page.getByRole('heading', { name })).toBeVisible({ + timeout: 10_000, + }); + } + + async gotoEdit(name: string) { + await this.page.goto(`${this.shadowBackendURL}/shadowlinks/${encodeURIComponent(name)}/edit`); + await expect(this.page.getByRole('heading', { name: /edit shadow link/i })).toBeVisible({ timeout: 10_000 }); + } + + /** + * Create wizard - Step 1: Connection + */ + async fillConnectionStep(params: { + name: string; + bootstrapServers: string; + username?: string; + password?: string; + mechanism?: 'SCRAM-SHA-256' | 'SCRAM-SHA-512'; + }) { + // Fill name using label + const nameInput = this.page.getByLabel(/shadow link name/i); + await nameInput.fill(params.name); + + // Fill bootstrap servers using testId (first input field) + const bootstrapInput = this.page.getByTestId('bootstrap-server-input-0'); + await bootstrapInput.fill(params.bootstrapServers); + + // Ensure TLS is turned OFF + const tlsToggle = this.page.getByTestId('tls-toggle'); + const isTlsChecked = await tlsToggle.isChecked(); + if (isTlsChecked) { + await tlsToggle.click(); + } + + // Configure SASL if provided + if (params.username && params.password) { + // Find and toggle SCRAM switch - look for the switch near "SCRAM" text + const scramSection = this.page.getByText('SCRAM').locator('..'); + const scramToggle = scramSection.getByRole('switch'); + + // Check if SCRAM is already enabled + const isScramEnabled = await scramToggle.isChecked(); + if (!isScramEnabled) { + await scramToggle.click(); + // Wait for username field to appear after toggle + await this.page.getByLabel(/username/i).waitFor({ state: 'visible', timeout: 5000 }); + } + + // Fill username and password using labels + const usernameInput = this.page.getByLabel(/username/i); + await usernameInput.fill(params.username); + + const passwordInput = this.page.getByLabel(/password/i); + await passwordInput.fill(params.password); + + if (params.mechanism) { + // Find mechanism combobox and click it + const mechanismButton = this.page.getByLabel(/mechanism/i); + await mechanismButton.click(); + + // Wait for dropdown to open and select the option + await this.page.getByRole('option', { name: params.mechanism }).click(); + } + } + + // Click Next button in stepper + const nextButton = this.page.getByRole('button', { name: /next/i }); + await nextButton.click(); + } + + /** + * Create wizard - Step 2: Configuration + */ + async fillConfigurationStep(params: { + topicFilters?: Array<{ + type: 'PREFIX' | 'LITERAL'; + filter: 'INCLUDE' | 'EXCLUDE'; + pattern: string; + }>; + syncInterval?: number; + consumerOffsets?: boolean; + aclSync?: boolean; + schemaRegistry?: boolean; + }) { + // Configure topic filters + if (params.topicFilters && params.topicFilters.length > 0) { + // Click "Specify topics" tab + const specifyTopicsTab = this.page.getByTestId('topics-specify-tab'); + await specifyTopicsTab.click(); + + // Wait for filters container to be visible + await this.page.getByTestId('topics-filters-container').waitFor({ state: 'visible' }); + + for (let i = 0; i < params.topicFilters.length; i++) { + const filter = params.topicFilters[i]; + + if (i > 0) { + // Add new filter + const addFilterBtn = this.page.getByTestId('add-topic-filter-button'); + await addFilterBtn.click(); + } + + // Determine the tab value based on filter type and pattern type + let tabValue = 'include-specific'; // default + if (filter.type === 'LITERAL' && filter.filter === 'INCLUDE') { + tabValue = 'include-specific'; + } else if (filter.type === 'PREFIX' && filter.filter === 'INCLUDE') { + tabValue = 'include-prefix'; + } else if (filter.type === 'LITERAL' && filter.filter === 'EXCLUDE') { + tabValue = 'exclude-specific'; + } else if (filter.type === 'PREFIX' && filter.filter === 'EXCLUDE') { + tabValue = 'exclude-prefix'; + } + + // Click the appropriate tab trigger + const tabTrigger = this.page.getByTestId(`topic-filter-${i}-${tabValue}`); + await tabTrigger.click(); + + // Fill in the pattern + const patternInput = this.page.getByTestId(`topic-filter-${i}-name`); + await patternInput.fill(filter.pattern); + } + } + + // Configure sync interval + if (params.syncInterval) { + const intervalInput = this.page.getByTestId('shadowlink-sync-interval-input'); + await intervalInput.fill(params.syncInterval.toString()); + } + + // Toggle consumer offsets + if (params.consumerOffsets !== undefined) { + const toggle = this.page.getByTestId('shadowlink-consumer-offsets-toggle'); + const isChecked = await toggle.isChecked(); + if (isChecked !== params.consumerOffsets) { + await toggle.click(); + } + } + + // Toggle ACL sync + if (params.aclSync !== undefined) { + const toggle = this.page.getByTestId('shadowlink-acl-sync-toggle'); + const isChecked = await toggle.isChecked(); + if (isChecked !== params.aclSync) { + await toggle.click(); + } + } + + // Toggle schema registry sync + if (params.schemaRegistry !== undefined) { + const toggle = this.page.getByTestId('shadowlink-schema-registry-toggle'); + const isChecked = await toggle.isChecked(); + if (isChecked !== params.schemaRegistry) { + await toggle.click(); + } + } + + // Click Create button + const createButton = this.page.getByRole('button', { name: /create/i }); + await createButton.click(); + } + + /** + * Complete create flow + */ + async createShadowlink(params: { + name: string; + bootstrapServers: string; + username?: string; + password?: string; + topicFilters?: Array<{ + type: 'PREFIX' | 'LITERAL'; + filter: 'INCLUDE' | 'EXCLUDE'; + pattern: string; + }>; + }) { + await this.gotoCreate(); + await this.fillConnectionStep(params); + await this.fillConfigurationStep({ topicFilters: params.topicFilters }); + + // Wait for navigation to list page (frontend redirects to /shadowlinks after create) + await this.page.waitForURL(/\/shadowlinks$/, { timeout: 60_000 }); + } + + /** + * Details page - Verification methods + */ + async verifyInList(name: string) { + await this.goto(); + const linkElement = this.page.getByRole('link', { name, exact: true }); + await expect(linkElement).toBeVisible({ timeout: 10_000 }); + } + + async verifyNotInList(name: string) { + await this.goto(); + const linkElement = this.page.getByRole('link', { name, exact: true }); + await expect(linkElement).not.toBeVisible(); + } + + async verifyOnHomePage(shadowlinkName: string, timeout = 30_000) { + // Navigate to home page (root after login) + await this.page.goto(this.shadowBackendURL); + + // Wait for home page to load - look for common elements + await expect(this.page.getByRole('heading', { name: /overview|dashboard|home/i })).toBeVisible({ timeout: 10_000 }); + + // Verify shadowlink section appears with retry logic + // The Shadow Cluster section may take time to appear after shadowlink creation + await expect(async () => { + await this.page.reload({ waitUntil: 'domcontentloaded', timeout: 5000 }); + + // Verify "Shadow Cluster" heading exists + const shadowClusterHeading = this.page.getByRole('heading', { name: /shadow cluster/i }); + await expect(shadowClusterHeading).toBeVisible(); + + // Verify the "Go to Shadow link" button exists + const goToShadowLinkButton = this.page.getByRole('button', { name: /go to shadow link/i }); + await expect(goToShadowLinkButton).toBeVisible(); + }).toPass({ timeout, intervals: [2000, 3000] }); + } + + async verifyStatus(expectedStatus: 'ACTIVE' | 'PAUSED' | 'FAILED_OVER', timeout = 30_000) { + await expect(async () => { + await this.page.reload({ waitUntil: 'domcontentloaded', timeout: 5000 }); + const statusBadge = this.page.getByTestId('shadowlink-status-badge'); + await expect(statusBadge).toContainText(expectedStatus); + }).toPass({ timeout, intervals: [2000, 3000] }); + } + + async verifyTopicCount(expectedCount: number, timeout = 30_000) { + // Wait for topics to sync + await expect(async () => { + const topicCountElement = this.page.getByTestId('shadowlink-topic-count'); + const text = await topicCountElement.textContent(); + const count = Number.parseInt(text || '0', 10); + expect(count).toBe(expectedCount); + }).toPass({ timeout }); + } + + async verifyTopicExists(topicName: string) { + // Look for the topic name in the topics table + const topicElement = this.page.getByText(topicName, { exact: true }); + await expect(topicElement).toBeVisible({ timeout: 30_000 }); + } + + async verifyMetrics( + expectedMetrics: { + totalTopics?: number; + failedOverTopics?: number; + errorTopics?: number; + }, + timeout = 90_000 + ) { + // Poll metrics values with periodic refresh button clicks + await expect(async () => { + // Try to click refresh button on replicated topics card to trigger refetch + const replicatedCard = this.page.getByTestId('shadow-link-metric-replicated'); + await replicatedCard.hover({ timeout: 500 }); + const refreshBtn = replicatedCard.locator('button:has(svg.lucide-refresh-cw)'); + await refreshBtn.click({ force: true, timeout: 500 }); + + // Check the values + if (expectedMetrics.totalTopics !== undefined) { + const totalElement = this.page.getByTestId('metric-value-replicated'); + const actualValue = await totalElement.textContent(); + expect(actualValue).toBe(expectedMetrics.totalTopics.toString()); + } + + if (expectedMetrics.failedOverTopics !== undefined) { + const failedOverElement = this.page.getByTestId('metric-value-failedover'); + const actualValue = await failedOverElement.textContent(); + expect(actualValue).toBe(expectedMetrics.failedOverTopics.toString()); + } + + if (expectedMetrics.errorTopics !== undefined) { + const errorElement = this.page.getByTestId('metric-value-error'); + const actualValue = await errorElement.textContent(); + expect(actualValue).toBe(expectedMetrics.errorTopics.toString()); + } + }).toPass({ timeout, intervals: [3000] }); + } + + /** + * Edit methods + */ + async updateTopicFilters( + filters: Array<{ + type: 'PREFIX' | 'LITERAL'; + filter: 'INCLUDE' | 'EXCLUDE'; + pattern: string; + }> + ) { + // Navigate to Shadowing tab + const shadowingTab = this.page.getByRole('tab', { name: /shadowing/i }); + await shadowingTab.click(); + + // Wait for the tab content to load + await this.page.waitForTimeout(500); + + // Find the "Shadow topics" section + const shadowTopicsHeading = this.page.getByRole('heading', { name: /shadow topics/i }); + await shadowTopicsHeading.waitFor({ state: 'visible' }); + + // ALWAYS try to expand the collapsible by clicking the chevron button + // The issue is that tabs might be visible in resume view but we need the full editable view + // The chevron button is right after the "Shadow topics" heading in the card header + // Simple approach: find all buttons with SVG, filter for the one that's actually a chevron icon + const buttonsForChevron = await this.page.getByRole('button').all(); + + let chevronClicked = false; + for (const btn of buttonsForChevron) { + // Check if this button contains an SVG with class matching chevron pattern + const isChevron = await btn.evaluate((button) => { + const svg = button.querySelector('svg'); + if (!svg) return false; + // ChevronDown has specific classes h-4 w-4 transition-transform + const classes = Array.from(svg.classList); + return ( + classes.includes('lucide-chevron-down') || + (classes.includes('h-4') && classes.includes('w-4') && classes.includes('transition-transform')) + ); + }); + + if (isChevron) { + await btn.click(); + await this.page.waitForTimeout(800); + chevronClicked = true; + break; + } + } + + if (!chevronClicked) { + throw new Error('Could not find chevron button to expand Shadow topics section'); + } + + // Now click the "Specify topics" tab + const specifyTab = this.page.getByRole('tab', { name: /specify topics/i }); + await specifyTab.waitFor({ state: 'visible', timeout: 5000 }); + await specifyTab.click(); + + // Wait for filter items to be visible + await this.page.waitForTimeout(1000); + + // Clear existing filters - look for delete/trash buttons in elevated cards + // Keep finding and clicking trash buttons until none remain + let foundTrashButton = true; + while (foundTrashButton) { + foundTrashButton = false; + const allButtons = await this.page.getByRole('button').all(); + + for (const btn of allButtons) { + try { + const isTrashButton = await btn.evaluate((button) => { + const svg = button.querySelector('svg'); + return svg?.classList.contains('lucide-trash'); + }); + + if (isTrashButton) { + await btn.click(); + await this.page.waitForTimeout(300); + foundTrashButton = true; + break; // Exit inner loop and re-query buttons + } + } catch (e) { + // Button may have been removed from DOM, continue to next + } + } + } + + // Add new filters + // After deleting all filters, we need to add new ones by clicking "Add filter" for each + let filterIndex = 0; + for (const filter of filters) { + // Click "Add filter" button to create a new filter card + const addBtn = this.page.getByRole('button', { name: /add filter/i }); + await addBtn.click(); + await this.page.waitForTimeout(800); + + // Determine the tab label based on filter type and pattern type + let tabLabel = ''; + if (filter.type === 'LITERAL' && filter.filter === 'INCLUDE') { + tabLabel = 'Include specific topics'; + } else if (filter.type === 'PREFIX' && filter.filter === 'INCLUDE') { + tabLabel = 'Include starting with'; + } else if (filter.type === 'LITERAL' && filter.filter === 'EXCLUDE') { + tabLabel = 'Exclude specific'; + } else if (filter.type === 'PREFIX' && filter.filter === 'EXCLUDE') { + tabLabel = 'Exclude starting with'; + } + + // Find all tabs with this label and click the one for this filter (filterIndex) + const allTabsWithLabel = this.page.getByRole('tab', { name: tabLabel }); + const targetTab = allTabsWithLabel.nth(filterIndex); + await targetTab.waitFor({ state: 'visible', timeout: 5000 }); + await targetTab.click(); + await this.page.waitForTimeout(300); + + // Use testId to find the specific input field directly + // The input has testId="topic-filter-{index}-name" + const inputInCard = this.page.locator(`[data-testid="topic-filter-${filterIndex}-name"]`); + await inputInCard.click(); + await inputInCard.fill(filter.pattern); + await this.page.waitForTimeout(200); + + filterIndex++; + } + + // TODO: this is a bug that needs resolving. + // Before saving, we need to go to the Source tab and fill the password + // The form requires SCRAM password even if we're only changing topic filters + const sourceTab = this.page.getByRole('tab', { name: /source/i }); + await sourceTab.click(); + await this.page.waitForTimeout(500); + + // Fill in the password field (required by form validation) + const passwordInput = this.page.getByRole('textbox', { name: /password/i }); + await passwordInput.click(); + await passwordInput.fill('very-secret'); + await this.page.waitForTimeout(200); + + // Save changes + const saveButton = this.page.getByRole('button', { name: /save/i }); + await saveButton.click(); + + // Wait for redirect back to details page (the save is successful if we're redirected) + // The URL should change from /shadowlinks/{name}/edit to /shadowlinks/{name} + await expect(this.page).toHaveURL(/\/shadowlinks\/[^/]+$/, { + timeout: 10_000, + }); + } + + /** + * Actions + */ + async performFailover() { + // Find "Failover all topics" button specifically (not individual topic failover buttons) + const failoverButton = this.page.getByRole('button', { name: /^failover all topics$/i }); + await failoverButton.click(); + + // Confirm in dialog + const confirmButton = this.page.getByRole('button', { name: /confirm|yes|failover/i }).last(); + await confirmButton.click(); + + // Wait a moment for failover to initiate + await this.page.waitForTimeout(1000); + } + + async failoverTopic(topicName: string) { + // Find the topic row and click failover action (should be on Overview tab) + const topicRow = this.page.getByRole('row').filter({ hasText: topicName }); + const failoverButton = topicRow.getByRole('button', { name: /failover/i }); + await failoverButton.click(); + + // Confirm in dialog + const confirmButton = this.page.getByRole('button', { name: /confirm|yes|failover/i }).last(); + await confirmButton.click(); + + // Wait a moment for failover to process + // await this.page.waitForTimeout(1000); + + // Click the refresh button for the replicated topics table + const refreshButton = this.page.getByTestId('refresh-topics-button'); + await refreshButton.click(); + await this.page.waitForTimeout(500); // Wait for refresh to complete + + // Verify the replication state changed to "Failed over" + const topicRowAfterRefresh = this.page.getByRole('row').filter({ hasText: topicName }); + await expect(topicRowAfterRefresh.getByText(/failed over/i)).toBeVisible({ timeout: 5000 }); + } + + async deleteShadowlink() { + // Find delete button by role and name + const deleteButton = this.page.getByRole('button', { name: /delete/i }); + await deleteButton.click(); + + // Wait for dialog to appear + await this.page.waitForTimeout(500); + + // Type "delete" in the confirmation textbox + const confirmInput = this.page.getByRole('textbox', { name: /type.*delete|confirm/i }); + await confirmInput.fill('delete'); + + // Wait a moment for the delete button to become enabled + await this.page.waitForTimeout(200); + + // Click final confirm button in the dialog + const confirmButton = this.page.getByRole('button', { name: /delete/i }).last(); + await confirmButton.click(); + + // Wait for redirect to list + await expect(this.page).toHaveURL(`${this.shadowBackendURL}/shadowlinks`, { timeout: 15_000 }); + + // Clean up replicated topics in destination cluster + await this.cleanupDestinationTopics(); + } + + async cleanupDestinationTopics() { + console.log('Cleaning up replicated topics in destination cluster...'); + try { + const { exec } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const { readFileSync, existsSync } = await import('node:fs'); + const { resolve } = await import('node:path'); + const execAsync = promisify(exec); + + // Read state file to get destination container ID + // Try both console-enterprise and enterprise naming conventions + const testsDir = resolve(__dirname, '../..'); + const possibleStateFiles = [ + resolve(testsDir, '.testcontainers-state-console-enterprise.json'), + resolve(testsDir, '.testcontainers-state-enterprise.json'), + ]; + + let containerId: string | undefined; + + for (const stateFilePath of possibleStateFiles) { + if (existsSync(stateFilePath)) { + try { + const stateContent = readFileSync(stateFilePath, 'utf-8'); + const state = JSON.parse(stateContent); + containerId = state.destRedpandaId; + if (containerId) { + console.log(` Found container ID in ${stateFilePath}`); + break; + } + } catch { + // Try next file + } + } + } + + if (!containerId) { + console.log(' Could not read state file, trying to find container by port...'); + // Fallback: try to find container by exposed port (19193 for console-enterprise, 19093 for console) + for (const port of ['19193', '19093']) { + const { stdout: containerByPort } = await execAsync(`docker ps -q --filter "publish=${port}"`); + containerId = containerByPort.trim(); + if (containerId) { + console.log(` Found container by port ${port}`); + break; + } + } + } + + if (!containerId) { + console.log(' No destination cluster container found, skipping cleanup'); + return; + } + + // List all topics in destination cluster with SASL auth + const saslFlags = '--user e2euser --password very-secret --sasl-mechanism SCRAM-SHA-256'; + const listCmd = `docker exec ${containerId.trim()} rpk topic list --brokers localhost:9092 ${saslFlags}`; + const { stdout } = await execAsync(listCmd); + + // Filter owlshop topics (the ones replicated by shadowlink) + const topics = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('owlshop-') && !line.includes('PARTITIONS')); + + if (topics.length === 0) { + console.log(' No topics to clean up'); + return; + } + + console.log(` Found ${topics.length} topics to delete: ${topics.join(', ')}`); + + // Delete each topic with SASL auth + for (const topic of topics) { + const deleteCmd = `docker exec ${containerId.trim()} rpk topic delete ${topic} --brokers localhost:9092 ${saslFlags}`; + await execAsync(deleteCmd); + console.log(` āœ“ Deleted topic: ${topic}`); + } + + console.log('āœ“ Cleanup complete'); + } catch (error) { + console.log(` Warning: Could not clean up destination topics: ${error.message}`); + } + } + + /** + * Helper methods + */ + async clickTab(tabName: 'Overview' | 'Tasks' | 'Configuration') { + const tab = this.page.getByRole('tab', { name: new RegExp(tabName, 'i') }); + await tab.click(); + await expect(tab).toHaveAttribute('aria-selected', 'true'); + } +} diff --git a/frontend/tests/console/utils/topic-page.ts b/frontend/tests/test-variant-console/utils/topic-page.ts similarity index 100% rename from frontend/tests/console/utils/topic-page.ts rename to frontend/tests/test-variant-console/utils/topic-page.ts diff --git a/frontend/tests/console/utils/transcript-page.ts b/frontend/tests/test-variant-console/utils/transcript-page.ts similarity index 100% rename from frontend/tests/console/utils/transcript-page.ts rename to frontend/tests/test-variant-console/utils/transcript-page.ts