From ef3b61f773368e55e55a441ca9a59a1db1cb3f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Sat, 27 Dec 2025 18:37:40 +0100 Subject: [PATCH 01/14] Added multiple e2e variants --- frontend/package.json | 8 +- frontend/tests/README.md | 246 ++++++++++++++++++ .../{ => console-enterprise}/auth.setup.ts | 0 .../console.enterprise.config.yaml | 0 .../console-enterprise/playwright.config.ts} | 55 ++-- .../console/connectors/connector.spec.ts | 2 +- .../{config => console}/console.config.yaml | 0 .../{ => tests/console}/playwright.config.ts | 46 ++-- .../{config => shared}/Dockerfile.backend | 0 .../conf/.bootstrap-dest.yaml | 0 .../{config => shared}/conf/.bootstrap.yaml | 0 .../tests/{ => shared}/connector.utils.ts | 2 +- .../console.dest.config.yaml | 0 frontend/tests/{ => shared}/global-setup.mjs | 209 +++++++-------- .../tests/{ => shared}/global-teardown.mjs | 10 +- frontend/tests/{ => shared}/schema.utils.ts | 0 frontend/tests/{ => shared}/seed.spec.ts | 0 17 files changed, 417 insertions(+), 161 deletions(-) rename frontend/tests/{ => console-enterprise}/auth.setup.ts (100%) rename frontend/tests/{config => console-enterprise}/console.enterprise.config.yaml (100%) rename frontend/{playwright.enterprise.config.ts => tests/console-enterprise/playwright.config.ts} (53%) rename frontend/tests/{config => console}/console.config.yaml (100%) rename frontend/{ => tests/console}/playwright.config.ts (54%) rename frontend/tests/{config => shared}/Dockerfile.backend (100%) rename frontend/tests/{config => shared}/conf/.bootstrap-dest.yaml (100%) rename frontend/tests/{config => shared}/conf/.bootstrap.yaml (100%) rename frontend/tests/{ => shared}/connector.utils.ts (98%) rename frontend/tests/{config => shared}/console.dest.config.yaml (100%) rename frontend/tests/{ => shared}/global-setup.mjs (84%) rename frontend/tests/{ => shared}/global-teardown.mjs (90%) rename frontend/tests/{ => shared}/schema.utils.ts (100%) rename frontend/tests/{ => shared}/seed.spec.ts (100%) diff --git a/frontend/package.json b/frontend/package.json index 811b79136..48016ac30 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,10 +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:console": "playwright test tests/console -c tests/console/playwright.config.ts", + "e2e-test:console:ui": "playwright test tests/console -c tests/console/playwright.config.ts --ui", + "e2e-test:enterprise": "playwright test tests/console-enterprise -c tests/console-enterprise/playwright.config.ts", + "e2e-test:enterprise:ui": "playwright test tests/console-enterprise -c tests/console-enterprise/playwright.config.ts --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/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/auth.setup.ts b/frontend/tests/console-enterprise/auth.setup.ts similarity index 100% rename from frontend/tests/auth.setup.ts rename to frontend/tests/console-enterprise/auth.setup.ts diff --git a/frontend/tests/config/console.enterprise.config.yaml b/frontend/tests/console-enterprise/console.enterprise.config.yaml similarity index 100% rename from frontend/tests/config/console.enterprise.config.yaml rename to frontend/tests/console-enterprise/console.enterprise.config.yaml diff --git a/frontend/playwright.enterprise.config.ts b/frontend/tests/console-enterprise/playwright.config.ts similarity index 53% rename from frontend/playwright.enterprise.config.ts rename to frontend/tests/console-enterprise/playwright.config.ts index 5dd8e736d..a932ea872 100644 --- a/frontend/playwright.enterprise.config.ts +++ b/frontend/tests/console-enterprise/playwright.config.ts @@ -1,68 +1,81 @@ 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. + * Playwright Test configuration for Enterprise (console-enterprise) variant */ export default defineConfig({ - timeout: 120 * 1000, // 2 minutes for overall test timeout (increased for CI stability) + // Extended timeout for shadowlink tests + timeout: 120 * 1000, + 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, - /* Number of parallel workers on CI - reduced for enterprise tests with multiple backend containers */ + + /* Reduced workers for enterprise/shadowlink setup */ workers: process.env.CI ? 2 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + + /* Reporter to use */ reporter: process.env.CI ? 'list' : 'html', + /* 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-enterprise', + variantName: 'console-enterprise', + configFile: 'console.enterprise.config.yaml', isEnterprise: true, - needsShadowlink: true, // Enables two-cluster setup for shadowlink tests + needsShadowlink: true, }, - /* 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('/')`. */ - baseURL: process.env.REACT_APP_ORIGIN ?? 'http://localhost:3000', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + /* 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 - traces are more useful anyway */ + /* Disable screenshots and videos in CI for better performance */ screenshot: 'off', video: 'off', }, - /* Configure projects for major browsers */ + /* Configure projects */ projects: [ + // Enterprise: Authentication setup project { name: 'authenticate', - testMatch: /auth\.setup\.ts/, + testMatch: '**/auth.setup.ts', }, + // Enterprise: Main test project with auth state { name: 'chromium', use: { ...devices['Desktop Chrome'], - // Use prepared auth state. + permissions: ['clipboard-read', 'clipboard-write'], storageState: 'playwright/.auth/user.json', }, dependencies: ['authenticate'], diff --git a/frontend/tests/console/connectors/connector.spec.ts b/frontend/tests/console/connectors/connector.spec.ts index 687edbbd5..0296754a2 100644 --- a/frontend/tests/console/connectors/connector.spec.ts +++ b/frontend/tests/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/tests/config/console.config.yaml b/frontend/tests/console/console.config.yaml similarity index 100% rename from frontend/tests/config/console.config.yaml rename to frontend/tests/console/console.config.yaml diff --git a/frontend/playwright.config.ts b/frontend/tests/console/playwright.config.ts similarity index 54% rename from frontend/playwright.config.ts rename to frontend/tests/console/playwright.config.ts index 7334e24ec..b0fef94af 100644 --- a/frontend/playwright.config.ts +++ b/frontend/tests/console/playwright.config.ts @@ -1,61 +1,69 @@ 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. + * Playwright Test configuration for OSS (console) variant */ export default 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: process.env.CI ? 'html' : 'html', + /* 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: 'on-first-retry', }, - /* 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', }, }, ], 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 98% rename from frontend/tests/connector.utils.ts rename to frontend/tests/shared/connector.utils.ts index f61a277d7..d6126b440 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 '../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 84% rename from frontend/tests/global-setup.mjs rename to frontend/tests/shared/global-setup.mjs index 01b619044..ea18ad46a 100644 --- a/frontend/tests/global-setup.mjs +++ b/frontend/tests/shared/global-setup.mjs @@ -13,8 +13,31 @@ 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`); + +// Port configuration for variants +const VARIANT_PORTS = { + console: { + backend: 3000, + redpandaKafka: 19092, + redpandaSchemaRegistry: 18081, + redpandaAdmin: 19644, + kafkaConnect: 18083, + }, + 'console-enterprise': { + backend: 3100, + backendDest: 3101, + redpandaKafka: 19192, + redpandaSchemaRegistry: 18181, + redpandaAdmin: 19744, + kafkaConnect: 18183, + // Shadowlink destination cluster ports + destRedpandaKafka: 19193, + destRedpandaSchemaRegistry: 18191, + destRedpandaAdmin: 19745, + }, +}; async function waitForPort(port, maxAttempts = 30, delayMs = 1000) { for (let i = 0; i < maxAttempts; i++) { @@ -48,16 +71,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: 19_092, host: ports.redpandaKafka }, + { container: 18_081, host: ports.redpandaSchemaRegistry }, { container: 18_082, host: 18_082 }, - { container: 9644, host: 19_644 } + { container: 9644, host: ports.redpandaAdmin } ) .withCommand([ 'redpanda', @@ -86,7 +109,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 +129,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 +213,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 +249,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 +268,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)'); @@ -281,10 +304,10 @@ async function buildBackendImage(isEnterprise) { 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,7 +330,7 @@ async function buildBackendImage(isEnterprise) { ); } - const frontendBuildDir = resolve(__dirname, '../build'); + const frontendBuildDir = resolve(__dirname, '../../build'); // Check if frontend build exists if (!existsSync(frontendBuildDir)) { @@ -329,7 +352,7 @@ async function buildBackendImage(isEnterprise) { // 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...'); @@ -360,14 +383,12 @@ async function buildBackendImage(isEnterprise) { } // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: (21) nested test environment setup with multiple configuration checks -async function startBackendServer(network, isEnterprise, imageTag, state) { +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 = isEnterprise - ? resolve(__dirname, 'config/console.enterprise.config.yaml') - : resolve(__dirname, 'config/console.config.yaml'); + const backendConfigPath = resolve(__dirname, '..', variantName, configFile); console.log(`Backend config path: ${backendConfigPath}`); @@ -398,9 +419,9 @@ async function startBackendServer(network, isEnterprise, imageTag, state) { // 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}`); @@ -429,7 +450,7 @@ async function startBackendServer(network, isEnterprise, imageTag, state) { console.log('Configuration summary:'); console.log(` - Network: ${network.getId ? network.getId() : 'unknown'}`); console.log(' - Alias: console-backend'); - console.log(' - Port: 3000:3000'); + console.log(` - Port: ${ports.backend}:3000`); console.log(' - Command: --config.filepath=/etc/console/config.yaml'); // Create container without wait strategy first to get the ID immediately @@ -437,7 +458,7 @@ async function startBackendServer(network, isEnterprise, imageTag, state) { .withNetwork(network) .withNetworkAliases('console-backend') .withNetworkMode(network.getName()) - .withExposedPorts({ container: 3000, host: 3000 }) + .withExposedPorts({ container: 3000, host: ports.backend }) .withBindMounts(bindMounts) .withCommand(['--config.filepath=/etc/console/config.yaml']); @@ -507,8 +528,8 @@ async function startBackendServer(network, isEnterprise, imageTag, state) { } 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}}'`); @@ -540,9 +561,10 @@ async function startBackendServer(network, isEnterprise, imageTag, state) { } // Now wait for port to be ready - console.log('Waiting for port 3000 to be ready...'); - await waitForPort(3000, 60, 1000); - console.log('āœ“ Port 3000 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); @@ -568,16 +590,16 @@ async function startBackendServer(network, isEnterprise, imageTag, state) { } } -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: 19_093, host: ports.destRedpandaKafka }, + { container: 18_091, host: ports.destRedpandaSchemaRegistry }, { container: 18_092, host: 18_092 }, - { container: 9644, host: 19_645 } + { container: 9644, host: ports.destRedpandaAdmin } ) .withCommand([ 'redpanda', @@ -606,7 +628,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', }, ]) @@ -626,16 +648,16 @@ async function startDestinationRedpandaContainer(network, state) { console.log(`āœ“ Destination Redpanda container started: ${state.destRedpandaId}`); } -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); + 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'); } @@ -669,7 +691,7 @@ 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)) { @@ -731,48 +753,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...'); @@ -821,14 +801,25 @@ 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 ports = VARIANT_PORTS[variantName]; console.log('\n\n========================================'); console.log( - `šŸš€ GLOBAL SETUP STARTED ${isEnterprise ? '(ENTERPRISE MODE)' : '(OSS MODE)'}${needsShadowlink ? ' + SHADOWLINK' : ''}` + `šŸš€ GLOBAL SETUP: ${variantName}${needsShadowlink ? ' + SHADOWLINK' : ''}` ); console.log('========================================\n'); + console.log('DEBUG - Config metadata:', { + variantName, + configFile, + isEnterprise, + needsShadowlink, + ports + }); console.log('Starting testcontainers environment...'); const state = { @@ -851,72 +842,70 @@ export default async function globalSetup(config = {}) { 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, '..', variantName, 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); + await startBackendServer(network, isEnterprise, imageTag, state, variantName, configFile, ports); } // Wait for services to be ready console.log('Waiting for backend to be ready...'); - await waitForPort(3000, 60, 1000); + await waitForPort(ports.backend, 60, 1000); console.log('Backend is ready'); console.log('Waiting for frontend to be ready...'); - await waitForPort(3000, 60, 1000); + await waitForPort(ports.backend, 60, 1000); // Give services extra time to stabilize in CI (especially shadowlink replication) if (isEnterprise && needsShadowlink && process.env.CI) { @@ -925,7 +914,7 @@ export default async function globalSetup(config = {}) { 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..fde2fd1b0 100644 --- a/frontend/tests/global-teardown.mjs +++ b/frontend/tests/shared/global-teardown.mjs @@ -8,14 +8,14 @@ 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 From d9ee956c1fc31f3e57b0f885c3cf38d93afa412e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 6 Jan 2026 21:48:05 +0100 Subject: [PATCH 02/14] Add support for e2e variants --- frontend/package.json | 6 +- .../shadowlink/shadowlink.spec.ts | 146 ----- .../tests/console/utils/shadowlink-page.ts | 607 ----------------- frontend/tests/scripts/discover-variants.mjs | 166 +++++ frontend/tests/scripts/run-all-variants.mjs | 101 +++ frontend/tests/scripts/run-variant.mjs | 80 +++ frontend/tests/shared/connector.utils.ts | 2 +- frontend/tests/shared/global-setup.mjs | 65 +- frontend/tests/shared/global-teardown.mjs | 3 +- .../acl.spec.ts | 4 +- .../auth.setup.ts | 0 .../config/console.config.yaml} | 0 .../config/variant.json | 19 + .../debug-bundle/debug-bundle.spec.ts | 2 +- .../license.spec.ts | 0 .../playwright.config.ts | 2 +- .../shadowlink/shadowlink.spec.ts | 146 +++++ .../users.spec.ts | 2 +- .../acls/acl.spec.ts | 0 .../config}/console.config.yaml | 0 .../test-variant-console/config/variant.json | 14 + .../connectors/connector.spec.ts | 0 .../playwright.config.ts | 0 .../schemas/schema.spec.ts | 0 .../topics/topic-creation.spec.ts | 0 .../topics/topic-list.spec.ts | 0 .../topics/topic-messages-actions.spec.ts | 0 .../topics/topic-messages-filtering.spec.ts | 0 .../topics/topic-messages-production.spec.ts | 0 .../topics/topic-navigation.spec.ts | 0 .../transforms/transforms.spec.ts | 0 .../utils/acl-page.ts | 0 .../utils/debug-bundle-page.ts | 0 .../utils/role-page.ts | 0 .../utils/schema-page.ts | 0 .../utils/security-page.ts | 0 .../utils/shadowlink-page.ts | 615 ++++++++++++++++++ .../utils/topic-page.ts | 0 38 files changed, 1181 insertions(+), 799 deletions(-) delete mode 100644 frontend/tests/console-enterprise/shadowlink/shadowlink.spec.ts delete mode 100644 frontend/tests/console/utils/shadowlink-page.ts create mode 100644 frontend/tests/scripts/discover-variants.mjs create mode 100644 frontend/tests/scripts/run-all-variants.mjs create mode 100644 frontend/tests/scripts/run-variant.mjs rename frontend/tests/{console-enterprise => test-variant-console-enterprise}/acl.spec.ts (99%) rename frontend/tests/{console-enterprise => test-variant-console-enterprise}/auth.setup.ts (100%) rename frontend/tests/{console-enterprise/console.enterprise.config.yaml => test-variant-console-enterprise/config/console.config.yaml} (100%) create mode 100644 frontend/tests/test-variant-console-enterprise/config/variant.json rename frontend/tests/{console-enterprise => test-variant-console-enterprise}/debug-bundle/debug-bundle.spec.ts (99%) rename frontend/tests/{console-enterprise => test-variant-console-enterprise}/license.spec.ts (100%) rename frontend/tests/{console-enterprise => test-variant-console-enterprise}/playwright.config.ts (97%) create mode 100644 frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts rename frontend/tests/{console-enterprise => test-variant-console-enterprise}/users.spec.ts (95%) rename frontend/tests/{console => test-variant-console}/acls/acl.spec.ts (100%) rename frontend/tests/{console => test-variant-console/config}/console.config.yaml (100%) create mode 100644 frontend/tests/test-variant-console/config/variant.json rename frontend/tests/{console => test-variant-console}/connectors/connector.spec.ts (100%) rename frontend/tests/{console => test-variant-console}/playwright.config.ts (100%) rename frontend/tests/{console => test-variant-console}/schemas/schema.spec.ts (100%) rename frontend/tests/{console => test-variant-console}/topics/topic-creation.spec.ts (100%) rename frontend/tests/{console => test-variant-console}/topics/topic-list.spec.ts (100%) rename frontend/tests/{console => test-variant-console}/topics/topic-messages-actions.spec.ts (100%) rename frontend/tests/{console => test-variant-console}/topics/topic-messages-filtering.spec.ts (100%) rename frontend/tests/{console => test-variant-console}/topics/topic-messages-production.spec.ts (100%) rename frontend/tests/{console => test-variant-console}/topics/topic-navigation.spec.ts (100%) rename frontend/tests/{console => test-variant-console}/transforms/transforms.spec.ts (100%) rename frontend/tests/{console => test-variant-console}/utils/acl-page.ts (100%) rename frontend/tests/{console => test-variant-console}/utils/debug-bundle-page.ts (100%) rename frontend/tests/{console => test-variant-console}/utils/role-page.ts (100%) rename frontend/tests/{console => test-variant-console}/utils/schema-page.ts (100%) rename frontend/tests/{console => test-variant-console}/utils/security-page.ts (100%) create mode 100644 frontend/tests/test-variant-console/utils/shadowlink-page.ts rename frontend/tests/{console => test-variant-console}/utils/topic-page.ts (100%) diff --git a/frontend/package.json b/frontend/package.json index 48016ac30..52ebe4ba9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,10 +15,8 @@ "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:console": "playwright test tests/console -c tests/console/playwright.config.ts", - "e2e-test:console:ui": "playwright test tests/console -c tests/console/playwright.config.ts --ui", - "e2e-test:enterprise": "playwright test tests/console-enterprise -c tests/console-enterprise/playwright.config.ts", - "e2e-test:enterprise:ui": "playwright test tests/console-enterprise -c tests/console-enterprise/playwright.config.ts --ui", + "e2e-test": "node tests/scripts/run-all-variants.mjs", + "e2e-test:variant": "node tests/scripts/run-variant.mjs", "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/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 9eda41cb3..000000000 --- a/frontend/tests/console/utils/shadowlink-page.ts +++ /dev/null @@ -1,607 +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 - 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 details page - await expect(this.page).toHaveURL(/\/shadowlinks\/.+/, { timeout: 15000 }); - } - - /** - * 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..df31109c3 --- /dev/null +++ b/frontend/tests/scripts/discover-variants.mjs @@ -0,0 +1,166 @@ +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' +); + +/** + * Parse license content and extract expiry timestamp + * License format: base64(JSON payload).signature + * @param {string} licenseContent - The full license string + * @returns {{valid: boolean, expiry: number|null, error: string|null}} + */ +function parseLicense(licenseContent) { + try { + // License format: base64payload.signature + const parts = licenseContent.split('.'); + if (parts.length < 1) { + return { valid: false, expiry: null, error: 'Invalid license format' }; + } + + const payloadBase64 = parts[0]; + const payloadJson = Buffer.from(payloadBase64, 'base64').toString('utf-8'); + const payload = JSON.parse(payloadJson); + + if (typeof payload.expiry !== 'number') { + return { valid: false, expiry: null, error: 'License missing expiry field' }; + } + + return { valid: true, expiry: payload.expiry, error: null }; + } catch (error) { + return { valid: false, expiry: null, error: `Failed to parse license: ${error.message}` }; + } +} + +/** + * 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}`, + }; + } + + // Check if the license is expired + let licenseContent = null; + if (hasLicenseEnv) { + licenseContent = process.env.ENTERPRISE_LICENSE_CONTENT; + } else if (hasLicenseFile) { + licenseContent = readFileSync(licensePath, 'utf-8').trim(); + } + + if (licenseContent) { + const { valid, expiry, error } = parseLicense(licenseContent); + if (!valid) { + return { + canRun: false, + reason: `License validation failed: ${error}`, + }; + } + + const now = Math.floor(Date.now() / 1000); + if (expiry < now) { + const expiryDate = new Date(expiry * 1000).toISOString().split('T')[0]; + return { + canRun: false, + reason: `License expired on ${expiryDate}. Please provide a valid license.`, + }; + } + } + } + + 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..e3b4216dc --- /dev/null +++ b/frontend/tests/scripts/run-all-variants.mjs @@ -0,0 +1,101 @@ +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, + }); + + 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..4eda47990 --- /dev/null +++ b/frontend/tests/scripts/run-variant.mjs @@ -0,0 +1,80 @@ +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, + }); + + 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/shared/connector.utils.ts b/frontend/tests/shared/connector.utils.ts index d6126b440..238f98780 100644 --- a/frontend/tests/shared/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/shared/global-setup.mjs b/frontend/tests/shared/global-setup.mjs index ea18ad46a..5862f13aa 100644 --- a/frontend/tests/shared/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,31 +13,24 @@ const __dirname = dirname(__filename); // Regex for extracting container ID from error messages const CONTAINER_ID_REGEX = /container ([a-f0-9]+)/; -const getStateFile = (variantName) => - resolve(__dirname, '..', `.testcontainers-state-${variantName}.json`); - -// Port configuration for variants -const VARIANT_PORTS = { - console: { - backend: 3000, - redpandaKafka: 19092, - redpandaSchemaRegistry: 18081, - redpandaAdmin: 19644, - kafkaConnect: 18083, - }, - 'console-enterprise': { - backend: 3100, - backendDest: 3101, - redpandaKafka: 19192, - redpandaSchemaRegistry: 18181, - redpandaAdmin: 19744, - kafkaConnect: 18183, - // Shadowlink destination cluster ports - destRedpandaKafka: 19193, - destRedpandaSchemaRegistry: 18191, - destRedpandaAdmin: 19745, - }, -}; +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++) { @@ -388,7 +381,7 @@ async function startBackendServer(network, isEnterprise, imageTag, state, varian console.log(`Image tag: ${imageTag}`); console.log(`Enterprise mode: ${isEnterprise}`); - const backendConfigPath = resolve(__dirname, '..', variantName, configFile); + const backendConfigPath = resolve(__dirname, '..', `test-variant-${variantName}`, 'config', configFile); console.log(`Backend config path: ${backendConfigPath}`); @@ -691,7 +684,10 @@ async function startBackendServerWithConfig( licensePath = `${tempDir}/redpanda.license`; writeFileSync(licensePath, process.env.ENTERPRISE_LICENSE_CONTENT); } else { - const defaultLicensePath = resolve(__dirname, '../../../../console-enterprise/frontend/tests/config/redpanda.license'); + const defaultLicensePath = resolve( + __dirname, + '../../../../console-enterprise/frontend/tests/config/redpanda.license' + ); licensePath = process.env.REDPANDA_LICENSE_PATH || defaultLicensePath; if (!fs.existsSync(licensePath)) { @@ -806,19 +802,20 @@ export default async function globalSetup(config = {}) { const configFile = config?.metadata?.configFile ?? 'console.config.yaml'; const isEnterprise = config?.metadata?.isEnterprise ?? false; const needsShadowlink = config?.metadata?.needsShadowlink ?? false; - const ports = VARIANT_PORTS[variantName]; + + // Load ports from variant's config/variant.json + const variantConfig = loadVariantConfig(variantName); + const ports = variantConfig.ports; console.log('\n\n========================================'); - console.log( - `šŸš€ GLOBAL SETUP: ${variantName}${needsShadowlink ? ' + SHADOWLINK' : ''}` - ); + console.log(`šŸš€ GLOBAL SETUP: ${variantName}${needsShadowlink ? ' + SHADOWLINK' : ''}`); console.log('========================================\n'); console.log('DEBUG - Config metadata:', { variantName, configFile, isEnterprise, needsShadowlink, - ports + ports, }); console.log('Starting testcontainers environment...'); @@ -872,7 +869,7 @@ export default async function globalSetup(config = {}) { // Start backend server(s) if (needsShadowlink) { // For shadowlink tests: start source backend on port ports.backend (existing data) - const sourceBackendConfigPath = resolve(__dirname, '..', variantName, configFile); + const sourceBackendConfigPath = resolve(__dirname, '..', `test-variant-${variantName}`, 'config', configFile); await startBackendServerWithConfig( network, isEnterprise, diff --git a/frontend/tests/shared/global-teardown.mjs b/frontend/tests/shared/global-teardown.mjs index fde2fd1b0..162c844a1 100644 --- a/frontend/tests/shared/global-teardown.mjs +++ b/frontend/tests/shared/global-teardown.mjs @@ -8,8 +8,7 @@ const execAsync = promisify(exec); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const getStateFile = (variantName) => - resolve(__dirname, '..', `.testcontainers-state-${variantName}.json`); +const getStateFile = (variantName) => resolve(__dirname, '..', `.testcontainers-state-${variantName}.json`); export default async function globalTeardown(config = {}) { const variantName = config?.metadata?.variantName ?? 'console'; 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/console-enterprise/auth.setup.ts b/frontend/tests/test-variant-console-enterprise/auth.setup.ts similarity index 100% rename from frontend/tests/console-enterprise/auth.setup.ts rename to frontend/tests/test-variant-console-enterprise/auth.setup.ts diff --git a/frontend/tests/console-enterprise/console.enterprise.config.yaml b/frontend/tests/test-variant-console-enterprise/config/console.config.yaml similarity index 100% rename from frontend/tests/console-enterprise/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..397b95733 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise/config/variant.json @@ -0,0 +1,19 @@ +{ + "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, + "kafkaConnect": 18183, + "destRedpandaKafka": 19193, + "destRedpandaSchemaRegistry": 18191, + "destRedpandaAdmin": 19745 + } +} 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/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/console-enterprise/playwright.config.ts b/frontend/tests/test-variant-console-enterprise/playwright.config.ts similarity index 97% rename from frontend/tests/console-enterprise/playwright.config.ts rename to frontend/tests/test-variant-console-enterprise/playwright.config.ts index a932ea872..d3fd773cb 100644 --- a/frontend/tests/console-enterprise/playwright.config.ts +++ b/frontend/tests/test-variant-console-enterprise/playwright.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ metadata: { variant: 'console-enterprise', variantName: 'console-enterprise', - configFile: 'console.enterprise.config.yaml', + configFile: 'console.config.yaml', isEnterprise: true, needsShadowlink: true, }, 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..35e1fdd72 --- /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 { expect, test } from '@playwright/test'; + +import { generateShadowlinkName, ShadowlinkPage } from '../../test-variant-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(); + }); + }); + }); +}); 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/console/console.config.yaml b/frontend/tests/test-variant-console/config/console.config.yaml similarity index 100% rename from frontend/tests/console/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..46658c825 --- /dev/null +++ b/frontend/tests/test-variant-console/config/variant.json @@ -0,0 +1,14 @@ +{ + "name": "console", + "displayName": "Console OSS", + "isEnterprise": false, + "needsShadowlink": false, + "needsAuth": false, + "ports": { + "backend": 3000, + "redpandaKafka": 19092, + "redpandaSchemaRegistry": 18081, + "redpandaAdmin": 19644, + "kafkaConnect": 18083 + } +} diff --git a/frontend/tests/console/connectors/connector.spec.ts b/frontend/tests/test-variant-console/connectors/connector.spec.ts similarity index 100% rename from frontend/tests/console/connectors/connector.spec.ts rename to frontend/tests/test-variant-console/connectors/connector.spec.ts diff --git a/frontend/tests/console/playwright.config.ts b/frontend/tests/test-variant-console/playwright.config.ts similarity index 100% rename from frontend/tests/console/playwright.config.ts rename to frontend/tests/test-variant-console/playwright.config.ts 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-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..4388bf2ff --- /dev/null +++ b/frontend/tests/test-variant-console/utils/shadowlink-page.ts @@ -0,0 +1,615 @@ +/** 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 = '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: 10_000 }); + } + + async gotoCreate() { + await this.page.goto(`${this.baseURL}/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.baseURL}/shadowlinks/${encodeURIComponent(name)}`); + await expect(this.page.getByRole('heading', { name })).toBeVisible({ + timeout: 10_000, + }); + } + + 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: 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 details page + await expect(this.page).toHaveURL(/\/shadowlinks\/.+/, { timeout: 15_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.baseURL); + + // 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.baseURL}/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 } = 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/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 From 18f1594e971c308c3f4cc21dbc4595c37a97f6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Wed, 7 Jan 2026 16:05:01 +0100 Subject: [PATCH 03/14] Redpanda e2e enterprise config --- frontend/tests/scripts/discover-variants.mjs | 55 ------------------- frontend/tests/scripts/run-all-variants.mjs | 5 ++ frontend/tests/shared/global-setup.mjs | 44 +++++++++++---- .../config/variant.json | 4 +- .../test-variant-console/config/variant.json | 1 + 5 files changed, 43 insertions(+), 66 deletions(-) diff --git a/frontend/tests/scripts/discover-variants.mjs b/frontend/tests/scripts/discover-variants.mjs index df31109c3..8ffa8afe5 100644 --- a/frontend/tests/scripts/discover-variants.mjs +++ b/frontend/tests/scripts/discover-variants.mjs @@ -12,34 +12,6 @@ const DEFAULT_LICENSE_PATH = resolve( '../../../../console-enterprise/frontend/tests/config/redpanda.license' ); -/** - * Parse license content and extract expiry timestamp - * License format: base64(JSON payload).signature - * @param {string} licenseContent - The full license string - * @returns {{valid: boolean, expiry: number|null, error: string|null}} - */ -function parseLicense(licenseContent) { - try { - // License format: base64payload.signature - const parts = licenseContent.split('.'); - if (parts.length < 1) { - return { valid: false, expiry: null, error: 'Invalid license format' }; - } - - const payloadBase64 = parts[0]; - const payloadJson = Buffer.from(payloadBase64, 'base64').toString('utf-8'); - const payload = JSON.parse(payloadJson); - - if (typeof payload.expiry !== 'number') { - return { valid: false, expiry: null, error: 'License missing expiry field' }; - } - - return { valid: true, expiry: payload.expiry, error: null }; - } catch (error) { - return { valid: false, expiry: null, error: `Failed to parse license: ${error.message}` }; - } -} - /** * Check if a variant can run based on its requirements * @param {object} config - Variant configuration @@ -58,33 +30,6 @@ function checkVariantRequirements(config) { reason: `License required but not found. Set ENTERPRISE_LICENSE_CONTENT env var or place license at ${licensePath}`, }; } - - // Check if the license is expired - let licenseContent = null; - if (hasLicenseEnv) { - licenseContent = process.env.ENTERPRISE_LICENSE_CONTENT; - } else if (hasLicenseFile) { - licenseContent = readFileSync(licensePath, 'utf-8').trim(); - } - - if (licenseContent) { - const { valid, expiry, error } = parseLicense(licenseContent); - if (!valid) { - return { - canRun: false, - reason: `License validation failed: ${error}`, - }; - } - - const now = Math.floor(Date.now() / 1000); - if (expiry < now) { - const expiryDate = new Date(expiry * 1000).toISOString().split('T')[0]; - return { - canRun: false, - reason: `License expired on ${expiryDate}. Please provide a valid license.`, - }; - } - } } return { canRun: true, reason: null }; diff --git a/frontend/tests/scripts/run-all-variants.mjs b/frontend/tests/scripts/run-all-variants.mjs index e3b4216dc..221331453 100644 --- a/frontend/tests/scripts/run-all-variants.mjs +++ b/frontend/tests/scripts/run-all-variants.mjs @@ -14,6 +14,11 @@ async function runVariant(variant, playwrightArgs = []) { const child = spawn('npx', args, { stdio: 'inherit', cwd: variant.path, + env: { + ...process.env, + // Prevent Playwright from starting interactive HTML report server + CI: 'true', + }, }); return new Promise((resolve) => { diff --git a/frontend/tests/shared/global-setup.mjs b/frontend/tests/shared/global-setup.mjs index 5862f13aa..73e50bc6d 100644 --- a/frontend/tests/shared/global-setup.mjs +++ b/frontend/tests/shared/global-setup.mjs @@ -36,19 +36,21 @@ 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)); @@ -72,7 +74,7 @@ async function startRedpandaContainer(network, state, ports) { .withExposedPorts( { container: 19_092, host: ports.redpandaKafka }, { container: 18_081, host: ports.redpandaSchemaRegistry }, - { container: 18_082, host: 18_082 }, + { container: 18_082, host: ports.redpandaPandaproxy }, { container: 9644, host: ports.redpandaAdmin } ) .withCommand([ @@ -89,7 +91,7 @@ async function startRedpandaContainer(network, state, ports) { '--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', @@ -591,7 +593,7 @@ async function startDestinationRedpandaContainer(network, state, ports) { .withExposedPorts( { container: 19_093, host: ports.destRedpandaKafka }, { container: 18_091, host: ports.destRedpandaSchemaRegistry }, - { container: 18_092, host: 18_092 }, + { container: 18_092, host: ports.destRedpandaPandaproxy }, { container: 9644, host: ports.destRedpandaAdmin } ) .withCommand([ @@ -608,7 +610,7 @@ async function startDestinationRedpandaContainer(network, state, ports) { '--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', @@ -639,12 +641,34 @@ async function startDestinationRedpandaContainer(network, state, ports) { 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, ports) { console.log('Waiting for destination Redpanda services...'); await new Promise((resolve) => setTimeout(resolve, 5000)); + // 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'); diff --git a/frontend/tests/test-variant-console-enterprise/config/variant.json b/frontend/tests/test-variant-console-enterprise/config/variant.json index 397b95733..654720abd 100644 --- a/frontend/tests/test-variant-console-enterprise/config/variant.json +++ b/frontend/tests/test-variant-console-enterprise/config/variant.json @@ -11,9 +11,11 @@ "redpandaKafka": 19192, "redpandaSchemaRegistry": 18181, "redpandaAdmin": 19744, + "redpandaPandaproxy": 18182, "kafkaConnect": 18183, "destRedpandaKafka": 19193, "destRedpandaSchemaRegistry": 18191, - "destRedpandaAdmin": 19745 + "destRedpandaAdmin": 19745, + "destRedpandaPandaproxy": 18192 } } diff --git a/frontend/tests/test-variant-console/config/variant.json b/frontend/tests/test-variant-console/config/variant.json index 46658c825..5f720bd8c 100644 --- a/frontend/tests/test-variant-console/config/variant.json +++ b/frontend/tests/test-variant-console/config/variant.json @@ -9,6 +9,7 @@ "redpandaKafka": 19092, "redpandaSchemaRegistry": 18081, "redpandaAdmin": 19644, + "redpandaPandaproxy": 18082, "kafkaConnect": 18083 } } From 04303ffd09e6fb4a49d87591d6e2b70d821d78f8 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:45:30 +0000 Subject: [PATCH 04/14] e2e: add shadowBackendURL fixture for enterprise variant Add custom Playwright fixture to pass shadowBackendURL config option to shadowlink tests, enabling proper port configuration (3101) for enterprise variant. Also improves container cleanup detection logic. --- .../fixtures.ts | 17 ++++ .../playwright.config.ts | 5 +- .../shadowlink/shadowlink.spec.ts | 14 ++-- .../utils/shadowlink-page.ts | 84 ++++++++++++------- 4 files changed, 81 insertions(+), 39 deletions(-) create mode 100644 frontend/tests/test-variant-console-enterprise/fixtures.ts 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..c958cd2c5 --- /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/test-variant-console-enterprise/playwright.config.ts b/frontend/tests/test-variant-console-enterprise/playwright.config.ts index d3fd773cb..7a962d233 100644 --- a/frontend/tests/test-variant-console-enterprise/playwright.config.ts +++ b/frontend/tests/test-variant-console-enterprise/playwright.config.ts @@ -61,7 +61,10 @@ export default defineConfig({ /* 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: [ diff --git a/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts b/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts index 35e1fdd72..f228b30ae 100644 --- a/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts @@ -1,12 +1,12 @@ /** biome-ignore-all lint/performance/useTopLevelRegex: e2e test */ -import { expect, test } from '@playwright/test'; +import { expect, test } from '../fixtures'; import { generateShadowlinkName, ShadowlinkPage } from '../../test-variant-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); + 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 () => { @@ -29,8 +29,8 @@ test.describe('Shadow Link E2E Tests', () => { }); }); - test('should create, update, failover, and delete shadowlink', async ({ page }) => { - const shadowlinkPage = new ShadowlinkPage(page); + 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 () => { @@ -104,8 +104,8 @@ test.describe('Shadow Link E2E Tests', () => { 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); + 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 () => { diff --git a/frontend/tests/test-variant-console/utils/shadowlink-page.ts b/frontend/tests/test-variant-console/utils/shadowlink-page.ts index 4388bf2ff..419737cbd 100644 --- a/frontend/tests/test-variant-console/utils/shadowlink-page.ts +++ b/frontend/tests/test-variant-console/utils/shadowlink-page.ts @@ -15,40 +15,43 @@ export function generateShadowlinkName() { * 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 + * 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 baseURL: string; + readonly shadowBackendURL: string; - constructor(page: Page, baseURL = 'http://localhost:3001') { + constructor(page: Page, shadowBackendURL?: string) { this.page = page; - this.baseURL = baseURL; + // 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.baseURL}/shadowlinks`); + 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.baseURL}/shadowlinks/create`); + 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.baseURL}/shadowlinks/${encodeURIComponent(name)}`); + 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.baseURL}/shadowlinks/${encodeURIComponent(name)}/edit`); + 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 }); } @@ -244,7 +247,7 @@ export class ShadowlinkPage { async verifyOnHomePage(shadowlinkName: string, timeout = 30_000) { // Navigate to home page (root after login) - await this.page.goto(this.baseURL); + 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 }); @@ -533,7 +536,7 @@ export class ShadowlinkPage { await confirmButton.click(); // Wait for redirect to list - await expect(this.page).toHaveURL(`${this.baseURL}/shadowlinks`, { timeout: 15_000 }); + await expect(this.page).toHaveURL(`${this.shadowBackendURL}/shadowlinks`, { timeout: 15_000 }); // Clean up replicated topics in destination cluster await this.cleanupDestinationTopics(); @@ -544,35 +547,54 @@ export class ShadowlinkPage { try { const { exec } = await import('node:child_process'); const { promisify } = await import('node:util'); - const { readFileSync } = await import('node:fs'); + 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 - 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; + // 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 + } } - } 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; + 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}`; From e47abd1dfb5d9effde949128ec84d75f98b7f1f8 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:09:18 +0000 Subject: [PATCH 05/14] e2e: increase timeout for shadowlink create navigation in CI --- frontend/tests/test-variant-console/utils/shadowlink-page.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/tests/test-variant-console/utils/shadowlink-page.ts b/frontend/tests/test-variant-console/utils/shadowlink-page.ts index 419737cbd..6d3c62e79 100644 --- a/frontend/tests/test-variant-console/utils/shadowlink-page.ts +++ b/frontend/tests/test-variant-console/utils/shadowlink-page.ts @@ -227,7 +227,8 @@ export class ShadowlinkPage { await this.fillConfigurationStep({ topicFilters: params.topicFilters }); // Wait for navigation to details page - await expect(this.page).toHaveURL(/\/shadowlinks\/.+/, { timeout: 15_000 }); + // CI is slower, so wait for the URL to change from /create to the details page + await this.page.waitForURL(/\/shadowlinks\/(?!create)[^/]+$/, { timeout: 60_000 }); } /** From ffd5ecd55cf72175666ee3b323d5752a1aa135a2 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:32:45 +0000 Subject: [PATCH 06/14] e2e: fix shadowlink create redirect expectation UI redirects to list page after create, not details page. --- frontend/tests/test-variant-console/utils/shadowlink-page.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/tests/test-variant-console/utils/shadowlink-page.ts b/frontend/tests/test-variant-console/utils/shadowlink-page.ts index 6d3c62e79..d9cf6703d 100644 --- a/frontend/tests/test-variant-console/utils/shadowlink-page.ts +++ b/frontend/tests/test-variant-console/utils/shadowlink-page.ts @@ -226,9 +226,8 @@ export class ShadowlinkPage { await this.fillConnectionStep(params); await this.fillConfigurationStep({ topicFilters: params.topicFilters }); - // Wait for navigation to details page - // CI is slower, so wait for the URL to change from /create to the details page - await this.page.waitForURL(/\/shadowlinks\/(?!create)[^/]+$/, { timeout: 60_000 }); + // Wait for navigation to list page (frontend redirects to /shadowlinks after create) + await this.page.waitForURL(/\/shadowlinks$/, { timeout: 60_000 }); } /** From cdc8f689785efee39c208bc6554c7ee1b20eedef Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:23:44 +0000 Subject: [PATCH 07/14] e2e: fix biome lint errors --- frontend/tests/test-variant-console-enterprise/fixtures.ts | 2 +- .../shadowlink/shadowlink.spec.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/tests/test-variant-console-enterprise/fixtures.ts b/frontend/tests/test-variant-console-enterprise/fixtures.ts index c958cd2c5..4834376c0 100644 --- a/frontend/tests/test-variant-console-enterprise/fixtures.ts +++ b/frontend/tests/test-variant-console-enterprise/fixtures.ts @@ -6,7 +6,7 @@ type CustomFixtures = { }; export const test = base.extend({ - shadowBackendURL: async ({ }, use, testInfo) => { + 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'; diff --git a/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts b/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts index f228b30ae..4f034a0a5 100644 --- a/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts @@ -1,6 +1,5 @@ /** biome-ignore-all lint/performance/useTopLevelRegex: e2e test */ -import { expect, test } from '../fixtures'; - +import { test } from '../fixtures'; import { generateShadowlinkName, ShadowlinkPage } from '../../test-variant-console/utils/shadowlink-page'; test.describe('Shadow Link E2E Tests', () => { From c4dacf2e6b4174522f919f49edf872216018d59f Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:23:44 +0000 Subject: [PATCH 08/14] e2e: fix biome lint errors --- .../shadowlink/shadowlink.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts b/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts index 4f034a0a5..488d75652 100644 --- a/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts +++ b/frontend/tests/test-variant-console-enterprise/shadowlink/shadowlink.spec.ts @@ -1,6 +1,7 @@ /** biome-ignore-all lint/performance/useTopLevelRegex: e2e test */ -import { test } from '../fixtures'; + 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', () => { From 351e030e32f531d1e7226fd72400c0db5cfd401b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Wed, 14 Jan 2026 13:49:03 +0100 Subject: [PATCH 09/14] Change PW reporters --- .../test-variant-console-enterprise/playwright.config.ts | 4 ++-- frontend/tests/test-variant-console/playwright.config.ts | 2 +- .../test-variant-console/topics/topic-create-defaults.spec.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/tests/test-variant-console-enterprise/playwright.config.ts b/frontend/tests/test-variant-console-enterprise/playwright.config.ts index 7a962d233..437ee13af 100644 --- a/frontend/tests/test-variant-console-enterprise/playwright.config.ts +++ b/frontend/tests/test-variant-console-enterprise/playwright.config.ts @@ -27,10 +27,10 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, /* Reduced workers for enterprise/shadowlink setup */ - workers: process.env.CI ? 2 : undefined, + workers: process.env.CI ? 4 : undefined, /* Reporter to use */ - reporter: process.env.CI ? 'list' : 'html', + reporter: process.env.CI ? [['github'], ['html']] : [['list'], ['html']], /* Global setup and teardown */ globalSetup: '../shared/global-setup.mjs', diff --git a/frontend/tests/test-variant-console/playwright.config.ts b/frontend/tests/test-variant-console/playwright.config.ts index 2e7aa3fb6..ee2a7d722 100644 --- a/frontend/tests/test-variant-console/playwright.config.ts +++ b/frontend/tests/test-variant-console/playwright.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ workers: process.env.CI ? 4 : undefined, /* Reporter to use */ - reporter: process.env.CI ? 'html' : 'html', + reporter: process.env.CI ? [['github'], ['html']] : [['list'], ['html']], /* Global setup and teardown */ globalSetup: '../shared/global-setup.mjs', diff --git a/frontend/tests/test-variant-console/topics/topic-create-defaults.spec.ts b/frontend/tests/test-variant-console/topics/topic-create-defaults.spec.ts index a413eac0d..b510e7b66 100644 --- a/frontend/tests/test-variant-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: { From 138fa461a169f5f6174058ace4ea15d6d86593e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Wed, 14 Jan 2026 14:06:28 +0100 Subject: [PATCH 10/14] Local reporters work now --- frontend/tests/scripts/run-all-variants.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/tests/scripts/run-all-variants.mjs b/frontend/tests/scripts/run-all-variants.mjs index 221331453..28053c40d 100644 --- a/frontend/tests/scripts/run-all-variants.mjs +++ b/frontend/tests/scripts/run-all-variants.mjs @@ -16,8 +16,6 @@ async function runVariant(variant, playwrightArgs = []) { cwd: variant.path, env: { ...process.env, - // Prevent Playwright from starting interactive HTML report server - CI: 'true', }, }); From 5d34aafba6916ee6cd30ab2be6e48567f7716dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Wed, 14 Jan 2026 14:21:09 +0100 Subject: [PATCH 11/14] Improve CI reporters - attempt 2 --- frontend/tests/scripts/run-all-variants.mjs | 5 ++++- frontend/tests/scripts/run-variant.mjs | 5 ++++- .../test-variant-console-enterprise/playwright.config.ts | 4 +++- frontend/tests/test-variant-console/playwright.config.ts | 4 +++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/tests/scripts/run-all-variants.mjs b/frontend/tests/scripts/run-all-variants.mjs index 28053c40d..432198443 100644 --- a/frontend/tests/scripts/run-all-variants.mjs +++ b/frontend/tests/scripts/run-all-variants.mjs @@ -9,7 +9,10 @@ async function runVariant(variant, playwrightArgs = []) { console.log(`Running variant: ${variant.name} (${variant.config.displayName})`); console.log(`${'='.repeat(60)}\n`); - const args = ['playwright', 'test', '--config', configPath, ...playwrightArgs]; + // Add explicit reporters in CI to ensure correct output format + const reporterArgs = process.env.CI ? ['--reporter=github', '--reporter=html'] : []; + + const args = ['playwright', 'test', '--config', configPath, ...reporterArgs, ...playwrightArgs]; const child = spawn('npx', args, { stdio: 'inherit', diff --git a/frontend/tests/scripts/run-variant.mjs b/frontend/tests/scripts/run-variant.mjs index 4eda47990..c0d5df34e 100644 --- a/frontend/tests/scripts/run-variant.mjs +++ b/frontend/tests/scripts/run-variant.mjs @@ -44,7 +44,10 @@ async function runVariant(variantName, playwrightArgs = []) { console.log(`Config: ${configPath}`); console.log(''); - const args = ['playwright', 'test', '--config', configPath, ...playwrightArgs]; + // Add explicit reporters in CI to ensure correct output format + const reporterArgs = process.env.CI ? ['--reporter=github', '--reporter=html'] : []; + + const args = ['playwright', 'test', '--config', configPath, ...reporterArgs, ...playwrightArgs]; const child = spawn('npx', args, { stdio: 'inherit', diff --git a/frontend/tests/test-variant-console-enterprise/playwright.config.ts b/frontend/tests/test-variant-console-enterprise/playwright.config.ts index 437ee13af..3ddd4f222 100644 --- a/frontend/tests/test-variant-console-enterprise/playwright.config.ts +++ b/frontend/tests/test-variant-console-enterprise/playwright.config.ts @@ -30,7 +30,9 @@ export default defineConfig({ workers: process.env.CI ? 4 : undefined, /* Reporter to use */ - reporter: process.env.CI ? [['github'], ['html']] : [['list'], ['html']], + reporter: process.env.CI + ? [['github'], ['html', { outputFolder: 'playwright-report' }]] + : [['list'], ['html', { outputFolder: 'playwright-report' }]], /* Global setup and teardown */ globalSetup: '../shared/global-setup.mjs', diff --git a/frontend/tests/test-variant-console/playwright.config.ts b/frontend/tests/test-variant-console/playwright.config.ts index ee2a7d722..a7f019e98 100644 --- a/frontend/tests/test-variant-console/playwright.config.ts +++ b/frontend/tests/test-variant-console/playwright.config.ts @@ -27,7 +27,9 @@ export default defineConfig({ workers: process.env.CI ? 4 : undefined, /* Reporter to use */ - reporter: process.env.CI ? [['github'], ['html']] : [['list'], ['html']], + reporter: process.env.CI + ? [['github'], ['html', { outputFolder: 'playwright-report' }]] + : [['list'], ['html', { outputFolder: 'playwright-report' }]], /* Global setup and teardown */ globalSetup: '../shared/global-setup.mjs', From 12acd0089b58a77d40192f9e1ac2923e972b09c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Wed, 14 Jan 2026 15:40:23 +0100 Subject: [PATCH 12/14] Change reporters attempt 3 --- frontend/tests/scripts/run-all-variants.mjs | 5 +---- frontend/tests/scripts/run-variant.mjs | 8 ++++---- .../playwright.config.ts | 19 +++++++++++++++---- .../test-variant-console/playwright.config.ts | 13 +++++++++---- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/frontend/tests/scripts/run-all-variants.mjs b/frontend/tests/scripts/run-all-variants.mjs index 432198443..28053c40d 100644 --- a/frontend/tests/scripts/run-all-variants.mjs +++ b/frontend/tests/scripts/run-all-variants.mjs @@ -9,10 +9,7 @@ async function runVariant(variant, playwrightArgs = []) { console.log(`Running variant: ${variant.name} (${variant.config.displayName})`); console.log(`${'='.repeat(60)}\n`); - // Add explicit reporters in CI to ensure correct output format - const reporterArgs = process.env.CI ? ['--reporter=github', '--reporter=html'] : []; - - const args = ['playwright', 'test', '--config', configPath, ...reporterArgs, ...playwrightArgs]; + const args = ['playwright', 'test', '--config', configPath, ...playwrightArgs]; const child = spawn('npx', args, { stdio: 'inherit', diff --git a/frontend/tests/scripts/run-variant.mjs b/frontend/tests/scripts/run-variant.mjs index c0d5df34e..74b6d257e 100644 --- a/frontend/tests/scripts/run-variant.mjs +++ b/frontend/tests/scripts/run-variant.mjs @@ -44,14 +44,14 @@ async function runVariant(variantName, playwrightArgs = []) { console.log(`Config: ${configPath}`); console.log(''); - // Add explicit reporters in CI to ensure correct output format - const reporterArgs = process.env.CI ? ['--reporter=github', '--reporter=html'] : []; - - const args = ['playwright', 'test', '--config', configPath, ...reporterArgs, ...playwrightArgs]; + const args = ['playwright', 'test', '--config', configPath, ...playwrightArgs]; const child = spawn('npx', args, { stdio: 'inherit', cwd: testsDir, + env: { + ...process.env, + }, }); return new Promise((resolve, reject) => { diff --git a/frontend/tests/test-variant-console-enterprise/playwright.config.ts b/frontend/tests/test-variant-console-enterprise/playwright.config.ts index 3ddd4f222..500b3fdcc 100644 --- a/frontend/tests/test-variant-console-enterprise/playwright.config.ts +++ b/frontend/tests/test-variant-console-enterprise/playwright.config.ts @@ -3,10 +3,21 @@ 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 */ -export default defineConfig({ +const config = defineConfig({ // Extended timeout for shadowlink tests timeout: 120 * 1000, @@ -30,9 +41,7 @@ export default defineConfig({ workers: process.env.CI ? 4 : undefined, /* Reporter to use */ - reporter: process.env.CI - ? [['github'], ['html', { outputFolder: 'playwright-report' }]] - : [['list'], ['html', { outputFolder: 'playwright-report' }]], + reporter: reporters, /* Global setup and teardown */ globalSetup: '../shared/global-setup.mjs', @@ -87,3 +96,5 @@ export default defineConfig({ }, ], }); + +export default config; diff --git a/frontend/tests/test-variant-console/playwright.config.ts b/frontend/tests/test-variant-console/playwright.config.ts index a7f019e98..e3dea7659 100644 --- a/frontend/tests/test-variant-console/playwright.config.ts +++ b/frontend/tests/test-variant-console/playwright.config.ts @@ -3,10 +3,15 @@ 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 OSS (console) variant */ -export default defineConfig({ +const config = defineConfig({ expect: { timeout: 60 * 1000, }, @@ -27,9 +32,7 @@ export default defineConfig({ workers: process.env.CI ? 4 : undefined, /* Reporter to use */ - reporter: process.env.CI - ? [['github'], ['html', { outputFolder: 'playwright-report' }]] - : [['list'], ['html', { outputFolder: 'playwright-report' }]], + reporter: reporters, /* Global setup and teardown */ globalSetup: '../shared/global-setup.mjs', @@ -74,3 +77,5 @@ export default defineConfig({ }, ], }); + +export default config; From 597039fdaad82e94aad14867157e541d70a481a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Wed, 14 Jan 2026 16:40:43 +0100 Subject: [PATCH 13/14] Update frontend/tests/test-variant-console-enterprise/playwright.config.ts Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../playwright.config.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/tests/test-variant-console-enterprise/playwright.config.ts b/frontend/tests/test-variant-console-enterprise/playwright.config.ts index 500b3fdcc..858f3b758 100644 --- a/frontend/tests/test-variant-console-enterprise/playwright.config.ts +++ b/frontend/tests/test-variant-console-enterprise/playwright.config.ts @@ -5,14 +5,8 @@ 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' }], - ]; + ? [['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 From e8a1ef259398e944f250bc3cf195e7297de46b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Fri, 16 Jan 2026 14:16:20 +0100 Subject: [PATCH 14/14] After merge udpate --- frontend/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 53ec59c4a..cfdf547bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,8 @@ "install:chromium": "bunx playwright install chromium", "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 playwright test tests/console/ -c playwright.config.ts --ui", + "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",