// "Write and run Playwright E2E tests for Redpanda Console using testcontainers. Analyzes test failures, adds missing testids, and improves test stability. Use when user requests E2E tests, Playwright tests, integration tests, test failures, missing testids, or mentions 'test workflow', 'browser testing', 'end-to-end', or 'testcontainers'."
| name | e2e-tester |
| description | Write and run Playwright E2E tests for Redpanda Console using testcontainers. Analyzes test failures, adds missing testids, and improves test stability. Use when user requests E2E tests, Playwright tests, integration tests, test failures, missing testids, or mentions 'test workflow', 'browser testing', 'end-to-end', or 'testcontainers'. |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep, Task, mcp__ide__getDiagnostics, mcp__playwright-test__test_run, mcp__playwright-test__test_list, mcp__playwright-test__test_debug |
Write end-to-end tests using Playwright against a full Redpanda Console stack running in Docker containers via testcontainers.
ALWAYS:
bun run build before running E2E tests (frontend assets required)testcontainers API for container management (never manual docker commands in tests)page.getByRole() and page.getByLabel() selectors (avoid CSS selectors)data-testid attributes to components when semantic selectors aren't availableNEVER:
.class-name or #idwaitFor with conditions).gitignore)OSS Mode (bun run e2e-test):
Enterprise Mode (bun run e2e-test-enterprise):
console-enterprise repo checked out alongside consoleNetwork Setup:
redpanda:9092, console-backend:3000localhost:19092, localhost:3000Setup (global-setup.mjs):
1. Build frontend (frontend/build/)
2. Copy frontend assets to backend/pkg/embed/frontend/
3. Build backend Docker image with testcontainers
4. Start Redpanda container with SASL auth
5. Start backend container serving frontend
6. Wait for services to be ready
Tests run...
Teardown (global-teardown.mjs):
1. Stop backend container
2. Stop Redpanda container
3. Remove Docker network
4. Clean up copied frontend assets
# Build frontend (REQUIRED before E2E tests)
bun run build
# Verify Docker is running
docker ps
File location: tests/<feature>/*.spec.ts
Structure:
import { test, expect } from '@playwright/test';
test.describe('Feature Name', () => {
test('user can complete workflow', async ({ page }) => {
// Navigate to page
await page.goto('/feature');
// Interact with elements
await page.getByRole('button', { name: 'Create' }).click();
await page.getByLabel('Name').fill('test-item');
// Submit and verify
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Success')).toBeVisible();
// Verify navigation or state change
await expect(page).toHaveURL(/\/feature\/test-item/);
});
});
Prefer accessibility selectors:
// ✅ GOOD: Role-based (accessible)
page.getByRole('button', { name: 'Create Topic' })
page.getByLabel('Topic Name')
page.getByText('Success message')
// ✅ GOOD: Test IDs when role isn't clear
page.getByTestId('topic-list-item')
// ❌ BAD: CSS selectors (brittle)
page.locator('.btn-primary')
page.locator('#topic-name-input')
Add test IDs to components:
// In React component
<Button data-testid="create-topic-button">
Create Topic
</Button>
// In test
await page.getByTestId('create-topic-button').click();
// ✅ GOOD: Wait for specific condition
await expect(page.getByRole('status')).toHaveText('Ready');
// ✅ GOOD: Wait for navigation
await page.waitForURL('**/topics/my-topic');
// ✅ GOOD: Wait for API response
await page.waitForResponse(resp =>
resp.url().includes('/api/topics') && resp.status() === 200
);
// ❌ BAD: Fixed timeouts
await page.waitForTimeout(5000);
OSS Mode: No authentication required
Enterprise Mode: Basic auth with e2euser:very-secret
test.use({
httpCredentials: {
username: 'e2euser',
password: 'very-secret',
},
});
# OSS tests
bun run build # Build frontend first!
bun run e2e-test # Run all OSS tests
# Enterprise tests (requires console-enterprise repo)
bun run build
bun run e2e-test-enterprise
# UI mode (debugging)
bun run e2e-test:ui
# Specific test file
bun run e2e-test tests/topics/create-topic.spec.ts
# Update snapshots
bun run e2e-test --update-snapshots
Failed test debugging:
# Check container logs
docker ps -a | grep console-backend
docker logs <container-id>
# Check if services are accessible
curl http://localhost:3000
curl http://localhost:19092
# Run with debug output
DEBUG=pw:api bun run e2e-test
# Keep containers running on failure
TESTCONTAINERS_RYUK_DISABLED=true bun run e2e-test
Playwright debugging tools:
// Add to test for debugging
await page.pause(); // Opens Playwright Inspector
// Screenshot on failure (automatic in config)
await page.screenshot({ path: 'debug.png' });
// Get page content for debugging
console.log(await page.content());
test('user creates, configures, and tests topic', async ({ page }) => {
// Step 1: Navigate and create
await page.goto('/topics');
await page.getByRole('button', { name: 'Create Topic' }).click();
// Step 2: Fill form
await page.getByLabel('Topic Name').fill('test-topic');
await page.getByLabel('Partitions').fill('3');
await page.getByRole('button', { name: 'Create' }).click();
// Step 3: Verify creation
await expect(page.getByText('Topic created successfully')).toBeVisible();
await expect(page).toHaveURL(/\/topics\/test-topic/);
// Step 4: Configure topic
await page.getByRole('button', { name: 'Configure' }).click();
await page.getByLabel('Retention Hours').fill('24');
await page.getByRole('button', { name: 'Save' }).click();
// Step 5: Verify configuration
await expect(page.getByText('Configuration saved')).toBeVisible();
});
test('form validation works correctly', async ({ page }) => {
await page.goto('/create-topic');
// Submit empty form - should show errors
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Name is required')).toBeVisible();
// Fill valid data - should succeed
await page.getByLabel('Topic Name').fill('valid-topic');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Success')).toBeVisible();
});
test('user can filter and sort topics', async ({ page }) => {
await page.goto('/topics');
// Filter
await page.getByPlaceholder('Search topics').fill('test-');
await expect(page.getByRole('row')).toHaveCount(3); // Header + 2 results
// Sort
await page.getByRole('columnheader', { name: 'Name' }).click();
const firstRow = page.getByRole('row').nth(1);
await expect(firstRow).toContainText('test-topic-a');
});
test('creating topic triggers backend API', async ({ page }) => {
// Listen for API call
const apiPromise = page.waitForResponse(
resp => resp.url().includes('/api/topics') && resp.status() === 201
);
// Trigger action
await page.goto('/topics');
await page.getByRole('button', { name: 'Create Topic' }).click();
await page.getByLabel('Name').fill('api-test-topic');
await page.getByRole('button', { name: 'Create' }).click();
// Verify API was called
const response = await apiPromise;
const body = await response.json();
expect(body.name).toBe('api-test-topic');
});
The backend Docker image needs frontend assets embedded at build time:
// In global-setup.mjs
async function buildBackendImage(isEnterprise) {
// Copy frontend build to backend embed directory
const frontendBuildDir = resolve(__dirname, '../build');
const embedDir = join(backendDir, 'pkg/embed/frontend');
await execAsync(`cp -r "${frontendBuildDir}"/* "${embedDir}"/`);
// Build Docker image using testcontainers
// Docker doesn't allow referencing files outside build context,
// so we temporarily copy the Dockerfile into the build context
const tempDockerfile = join(backendDir, '.dockerfile.e2e.tmp');
await execAsync(`cp "${dockerfilePath}" "${tempDockerfile}"`);
try {
await GenericContainer
.fromDockerfile(backendDir, '.dockerfile.e2e.tmp')
.build(imageTag, { deleteOnExit: false });
} finally {
await execAsync(`rm -f "${tempDockerfile}"`).catch(() => {});
await execAsync(`find "${embedDir}" -mindepth 1 ! -name '.gitignore' -delete`).catch(() => {});
}
}
Backend container:
const backend = await new GenericContainer(imageTag)
.withNetwork(network)
.withNetworkAliases('console-backend')
.withExposedPorts({ container: 3000, host: 3000 })
.withBindMounts([{
source: configPath,
target: '/etc/console/config.yaml'
}])
.withCommand(['--config.filepath=/etc/console/config.yaml'])
.start();
Redpanda container:
const redpanda = await new GenericContainer('redpandadata/redpanda:v25.2.1')
.withNetwork(network)
.withNetworkAliases('redpanda')
.withExposedPorts(
{ container: 19_092, host: 19_092 }, // Kafka
{ container: 18_081, host: 18_081 }, // Schema Registry
{ container: 9644, host: 19_644 } // Admin API
)
.withEnvironment({ RP_BOOTSTRAP_USER: 'e2euser:very-secret' })
.withHealthCheck({
test: ['CMD-SHELL', "rpk cluster health | grep -E 'Healthy:.+true' || exit 1"],
interval: 15_000,
retries: 5
})
.withWaitStrategy(Wait.forHealthCheck())
.start();
e2e-test:
runs-on: ubuntu-latest-8
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build frontend
run: |
REACT_APP_CONSOLE_GIT_SHA=$(echo $GITHUB_SHA | cut -c 1-6)
bun run build
- name: Install Playwright browsers
run: bun run install:chromium
- name: Run E2E tests
run: bun run e2e-test
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report/
Use the Task tool with Explore agent to systematically find missing testids:
Use Task tool with:
subagent_type: Explore
prompt: Search through [feature] UI components and identify all interactive
elements (buttons, inputs, links, selects) missing data-testid attributes.
List with file:line, element type, purpose, and suggested testid name.
Example output:
schema-list.tsx:207 - button - "Edit compatibility" - schema-edit-compatibility-btn
schema-list.tsx:279 - button - "Create new schema" - schema-create-new-btn
schema-details.tsx:160 - button - "Edit compatibility" - schema-details-edit-compatibility-btn
Naming Convention:
data-testid="feature-action-element"data-testid={\item-delete-${id}`}`Examples:
// ✅ GOOD: Specific button action
<Button data-testid="schema-create-new-btn" onClick={onCreate}>
Create new schema
</Button>
// ✅ GOOD: Form input with context
<Input
data-testid="schema-subject-name-input"
placeholder="Subject name"
/>
// ✅ GOOD: Table row with dynamic ID
<TableRow data-testid={`schema-row-${schema.name}`}>
{schema.name}
</TableRow>
// ✅ GOOD: Delete button in list
<IconButton
data-testid={`schema-delete-btn-${schema.name}`}
icon={<TrashIcon />}
onClick={() => deleteSchema(schema.name)}
/>
// ❌ BAD: Too generic
<Button data-testid="button">Create</Button>
// ❌ BAD: Using CSS classes as identifiers
<Button className="create-btn">Create</Button>
Where to Add:
Process:
data-testid following naming conventionCheck Test Status:
// Use mcp__playwright-test__test_list to see all tests
// Use mcp__playwright-test__test_run to get detailed results
// Use mcp__playwright-test__test_debug to analyze specific failures
Common failure patterns and fixes:
Error: locator.click: Target closed
Error: Timeout 30000ms exceeded waiting for locator
Analysis steps:
Fix:
// ❌ BAD: Element might not be loaded
await page.getByRole('button', { name: 'Create' }).click();
// ✅ GOOD: Wait for element to be visible
await expect(page.getByRole('button', { name: 'Create' })).toBeVisible();
await page.getByRole('button', { name: 'Create' }).click();
// ✅ BETTER: Add testid for stability
await page.getByTestId('create-button').click();
Error: strict mode violation: locator('button') resolved to 3 elements
Analysis:
Fix:
// ❌ BAD: Multiple "Edit" buttons on page
await page.getByRole('button', { name: 'Edit' }).click();
// ✅ GOOD: More specific with testid
await page.getByTestId('schema-edit-compatibility-btn').click();
// ✅ GOOD: Scope within container
await page.getByRole('region', { name: 'Schema Details' })
.getByRole('button', { name: 'Edit' }).click();
Error: expect(locator).toHaveText()
Expected string: "Success"
Received string: "Loading..."
Analysis:
Fix:
// ❌ BAD: Doesn't wait for state change
await page.getByRole('button', { name: 'Save' }).click();
expect(page.getByText('Success')).toBeVisible();
// ✅ GOOD: Wait for the expected state
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Success')).toBeVisible({ timeout: 5000 });
Error: page.goto: net::ERR_CONNECTION_REFUSED
Analysis:
Fix:
# Check containers are running
docker ps | grep console-backend
# Check container logs
docker logs <container-id>
# Verify port mapping
curl http://localhost:3000
# Check testcontainer state file
cat .testcontainers-state.json
When tests fail:
Get Test Results
Use mcp__playwright-test__test_run or check console output
Identify which tests failed and error messages
Analyze Error Patterns
Find Missing Test IDs
Use Task tool with Explore agent to find missing testids in the
components related to failed tests
Add Test IDs
data-testid to problematic elementsUpdate Tests
Verify Fixes
Run specific test file to verify fix
Run full suite to ensure no regressions
# Check if frontend build exists
ls frontend/build/
# Check if Docker image built successfully
docker images | grep console-backend
# Check container logs
docker logs <container-id>
# Verify Docker network
docker network ls | grep testcontainers
// Increase timeout for slow operations
test('slow operation', async ({ page }) => {
test.setTimeout(60000); // 60 seconds
await page.goto('/slow-page');
await expect(page.getByText('Loaded')).toBeVisible({ timeout: 30000 });
});
# Find and kill process using port 3000
lsof -ti:3000 | xargs kill -9
# Or use different ports in test config
Test types:
*.spec.ts): Complete user workflows, browser interactions*.test.tsx): Component + API, no browser*.test.ts): Pure logic, utilitiesCommands:
bun run build # Build frontend (REQUIRED first!)
bun run e2e-test # Run OSS E2E tests
bun run e2e-test-enterprise # Run Enterprise E2E tests
bun run e2e-test:ui # Playwright UI mode (debugging)
Selector priority:
getByRole() - Best for accessibilitygetByLabel() - For form inputsgetByText() - For content verificationgetByTestId() - When semantic selectors aren't clearWait strategies:
waitForURL() - Navigation completewaitForResponse() - API call finishedwaitFor() with expect() - Element state changedwaitForTimeout() unless absolutely necessaryAfter completing work: