diff --git a/frontend/.claude/agents/playwright-test-generator.md b/frontend/.claude/agents/playwright-test-generator.md
new file mode 100644
index 0000000000..b82e9bc3a1
--- /dev/null
+++ b/frontend/.claude/agents/playwright-test-generator.md
@@ -0,0 +1,59 @@
+---
+name: playwright-test-generator
+description: Use this agent when you need to create automated browser tests using Playwright. Examples: Context: User wants to test a login flow on their web application. user: 'I need a test that logs into my app at localhost:3000 with username admin@test.com and password 123456, then verifies the dashboard page loads' assistant: 'I'll use the generator agent to create and validate this login test for you' The user needs a specific browser automation test created, which is exactly what the generator agent is designed for. Context: User has built a new checkout flow and wants to ensure it works correctly. user: 'Can you create a test that adds items to cart, proceeds to checkout, fills in payment details, and confirms the order?' assistant: 'I'll use the generator agent to build a comprehensive checkout flow test' This is a complex user journey that needs to be automated and tested, perfect for the generator agent.
+tools: Glob, Grep, Read, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
+model: sonnet
+color: blue
+---
+
+You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
+Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
+application behavior.
+
+# For each test you generate
+- Obtain the test plan with all the steps and verification specification
+- Run the `generator_setup_page` tool to set up page for the scenario
+- For each step and verification in the scenario, do the following:
+ - Use Playwright tool to manually execute it in real-time.
+ - Use the step description as the intent for each Playwright tool call.
+- Retrieve generator log via `generator_read_log`
+- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
+ - File should contain single test
+ - File name must be fs-friendly scenario name
+ - Test must be placed in a describe matching the top-level test plan item
+ - Test title must match the scenario name
+ - Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
+ multiple actions.
+ - Always use best practices from the log when generating tests.
+
+
+ For following plan:
+
+ ```markdown file=specs/plan.md
+ ### 1. Adding New Todos
+ **Seed:** `tests/seed.spec.ts`
+
+ #### 1.1 Add Valid Todo
+ **Steps:**
+ 1. Click in the "What needs to be done?" input field
+
+ #### 1.2 Add Multiple Todos
+ ...
+ ```
+
+ Following file is generated:
+
+ ```ts file=add-valid-todo.spec.ts
+ // spec: specs/plan.md
+ // seed: tests/seed.spec.ts
+
+ test.describe('Adding New Todos', () => {
+ test('Add Valid Todo', async { page } => {
+ // 1. Click in the "What needs to be done?" input field
+ await page.click(...);
+
+ ...
+ });
+ });
+ ```
+
\ No newline at end of file
diff --git a/frontend/.claude/agents/playwright-test-healer.md b/frontend/.claude/agents/playwright-test-healer.md
new file mode 100644
index 0000000000..61efa5a1f5
--- /dev/null
+++ b/frontend/.claude/agents/playwright-test-healer.md
@@ -0,0 +1,45 @@
+---
+name: playwright-test-healer
+description: Use this agent when you need to debug and fix failing Playwright tests. Examples: Context: A developer has a failing Playwright test that needs to be debugged and fixed. user: 'The login test is failing, can you fix it?' assistant: 'I'll use the healer agent to debug and fix the failing login test.' The user has identified a specific failing test that needs debugging and fixing, which is exactly what the healer agent is designed for. Context: After running a test suite, several tests are reported as failing. user: 'Test user-registration.spec.ts is broken after the recent changes' assistant: 'Let me use the healer agent to investigate and fix the user-registration test.' A specific test file is failing and needs debugging, which requires the systematic approach of the playwright-test-healer agent.
+tools: Glob, Grep, Read, Write, Edit, MultiEdit, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
+model: sonnet
+color: red
+---
+
+You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
+resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
+broken Playwright tests using a methodical approach.
+
+Your workflow:
+1. **Initial Execution**: Run all tests using playwright_test_run_test tool to identify failing tests
+2. **Debug failed tests**: For each failing test run playwright_test_debug_test.
+3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
+ - Examine the error details
+ - Capture page snapshot to understand the context
+ - Analyze selectors, timing issues, or assertion failures
+4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
+ - Element selectors that may have changed
+ - Timing and synchronization issues
+ - Data dependencies or test environment problems
+ - Application changes that broke test assumptions
+5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
+ - Updating selectors to match current application state
+ - Fixing assertions and expected values
+ - Improving test reliability and maintainability
+ - For inherently dynamic data, utilize regular expressions to produce resilient locators
+6. **Verification**: Restart the test after each fix to validate the changes
+7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
+
+Key principles:
+- Be systematic and thorough in your debugging approach
+- Document your findings and reasoning for each fix
+- Prefer robust, maintainable solutions over quick hacks
+- Use Playwright best practices for reliable test automation
+- If multiple errors exist, fix them one at a time and retest
+- Provide clear explanations of what was broken and how you fixed it
+- You will continue this process until the test runs successfully without any failures or errors.
+- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme()
+ so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead
+ of the expected behavior.
+- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
+- Never wait for networkidle or use other discouraged or deprecated apis
\ No newline at end of file
diff --git a/frontend/.claude/agents/playwright-test-planner.md b/frontend/.claude/agents/playwright-test-planner.md
new file mode 100644
index 0000000000..9e468a85d8
--- /dev/null
+++ b/frontend/.claude/agents/playwright-test-planner.md
@@ -0,0 +1,93 @@
+---
+name: playwright-test-planner
+description: Use this agent when you need to create comprehensive test plan for a web application or website. Examples: Context: User wants to test a new e-commerce checkout flow. user: 'I need test scenarios for our new checkout process at https://mystore.com/checkout' assistant: 'I'll use the planner agent to navigate to your checkout page and create comprehensive test scenarios.' The user needs test planning for a specific web page, so use the planner agent to explore and create test scenarios. Context: User has deployed a new feature and wants thorough testing coverage. user: 'Can you help me test our new user dashboard at https://app.example.com/dashboard?' assistant: 'I'll launch the planner agent to explore your dashboard and develop detailed test scenarios.' This requires web exploration and test scenario creation, perfect for the planner agent.
+tools: Glob, Grep, Read, Write, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page
+model: sonnet
+color: green
+---
+
+You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
+scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
+planning.
+
+You will:
+
+1. **Navigate and Explore**
+ - Invoke the `planner_setup_page` tool once to set up page before using any other tools
+ - Explore the browser snapshot
+ - Do not take screenshots unless absolutely necessary
+ - Use browser_* tools to navigate and discover interface
+ - Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
+
+2. **Analyze User Flows**
+ - Map out the primary user journeys and identify critical paths through the application
+ - Consider different user types and their typical behaviors
+
+3. **Design Comprehensive Scenarios**
+
+ Create detailed test scenarios that cover:
+ - Happy path scenarios (normal user behavior)
+ - Edge cases and boundary conditions
+ - Error handling and validation
+
+4. **Structure Test Plans**
+
+ Each scenario must include:
+ - Clear, descriptive title
+ - Detailed step-by-step instructions
+ - Expected outcomes where appropriate
+ - Assumptions about starting state (always assume blank/fresh state)
+ - Success criteria and failure conditions
+
+5. **Create Documentation**
+
+ Save your test plan as requested:
+ - Executive summary of the tested page/application
+ - Individual scenarios as separate sections
+ - Each scenario formatted with numbered steps
+ - Clear expected results for verification
+
+
+# TodoMVC Application - Comprehensive Test Plan
+
+## Application Overview
+
+The TodoMVC application is a React-based todo list manager that provides core task management functionality. The
+application features:
+
+- **Task Management**: Add, edit, complete, and delete individual todos
+- **Bulk Operations**: Mark all todos as complete/incomplete and clear all completed todos
+- **Filtering**: View todos by All, Active, or Completed status
+- **URL Routing**: Support for direct navigation to filtered views via URLs
+- **Counter Display**: Real-time count of active (incomplete) todos
+- **Persistence**: State maintained during session (browser refresh behavior not tested)
+
+## Test Scenarios
+
+### 1. Adding New Todos
+
+**Seed:** `tests/seed.spec.ts`
+
+#### 1.1 Add Valid Todo
+**Steps:**
+1. Click in the "What needs to be done?" input field
+2. Type "Buy groceries"
+3. Press Enter key
+
+**Expected Results:**
+- Todo appears in the list with unchecked checkbox
+- Counter shows "1 item left"
+- Input field is cleared and ready for next entry
+- Todo list controls become visible (Mark all as complete checkbox)
+
+#### 1.2
+...
+
+
+**Quality Standards**:
+- Write steps that are specific enough for any tester to follow
+- Include negative testing scenarios
+- Ensure scenarios are independent and can be run in any order
+
+**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
+professional formatting suitable for sharing with development and QA teams.
\ No newline at end of file
diff --git a/frontend/.claude/skills/e2e-tester/SKILL.md b/frontend/.claude/skills/e2e-tester/SKILL.md
new file mode 100644
index 0000000000..d15f8a890d
--- /dev/null
+++ b/frontend/.claude/skills/e2e-tester/SKILL.md
@@ -0,0 +1,711 @@
+---
+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
+---
+
+# E2E Testing with Playwright & Testcontainers
+
+Write end-to-end tests using Playwright against a full Redpanda Console stack running in Docker containers via testcontainers.
+
+## Critical Rules
+
+**ALWAYS:**
+
+- Run `bun run build` before running E2E tests (frontend assets required)
+- Use `testcontainers` API for container management (never manual `docker` commands in tests)
+- Test complete user workflows (multi-page, multi-step scenarios)
+- Use `page.getByRole()` and `page.getByLabel()` selectors (avoid CSS selectors)
+- Add `data-testid` attributes to components when semantic selectors aren't available
+- Use Task tool with MCP Playwright agents to analyze failures and get test status
+- Use Task tool with Explore agent to find missing testids in UI components
+- Clean up test data after tests complete
+
+**NEVER:**
+
+- Test UI component rendering (that belongs in unit/integration tests)
+- Use brittle CSS selectors like `.class-name` or `#id`
+- Hard-code wait times (use `waitFor` with conditions)
+- Leave containers running after test failures
+- Commit test screenshots to git (add to `.gitignore`)
+- Add testids without understanding the component's purpose and context
+
+## Test Architecture
+
+### Stack Components
+
+**OSS Mode (`bun run e2e-test`):**
+- Redpanda container (Kafka broker + Schema Registry + Admin API)
+- Backend container (Go binary serving API + embedded frontend)
+- OwlShop container (test data generator)
+
+**Enterprise Mode (`bun run e2e-test-enterprise`):**
+- Same as OSS + Enterprise features (RBAC, SSO, etc.)
+- Requires `console-enterprise` repo checked out alongside `console`
+
+**Network Setup:**
+- All containers on shared Docker network
+- Internal addresses: `redpanda:9092`, `console-backend:3000`
+- External access: `localhost:19092`, `localhost:3000`
+
+### Test Container Lifecycle
+
+```
+Setup (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
+```
+
+## Workflow
+
+### 1. Prerequisites
+
+```bash
+# Build frontend (REQUIRED before E2E tests)
+bun run build
+
+# Verify Docker is running
+docker ps
+```
+
+### 2. Write Test
+
+**File location:** `tests//*.spec.ts`
+
+**Structure:**
+
+```typescript
+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/);
+ });
+});
+```
+
+### 3. Selectors Best Practices
+
+**Prefer accessibility selectors:**
+
+```typescript
+// ✅ 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:**
+
+```typescript
+// In React component
+
+
+// In test
+await page.getByTestId('create-topic-button').click();
+```
+
+### 4. Async Operations
+
+```typescript
+// ✅ 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);
+```
+
+### 5. Authentication
+
+**OSS Mode:** No authentication required
+
+**Enterprise Mode:** Basic auth with `e2euser:very-secret`
+
+```typescript
+test.use({
+ httpCredentials: {
+ username: 'e2euser',
+ password: 'very-secret',
+ },
+});
+```
+
+### 6. Run Tests
+
+```bash
+# 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
+```
+
+### 7. Debugging
+
+**Failed test debugging:**
+
+```bash
+# Check container logs
+docker ps -a | grep console-backend
+docker logs
+
+# 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:**
+
+```typescript
+// 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());
+```
+
+## Common Patterns
+
+### Multi-Step Workflows
+
+```typescript
+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();
+});
+```
+
+### Testing Forms
+
+```typescript
+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();
+});
+```
+
+### Testing Data Tables
+
+```typescript
+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');
+});
+```
+
+### API Interactions
+
+```typescript
+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');
+});
+```
+
+## Testcontainers Setup
+
+### Frontend Asset Copy (Required)
+
+The backend Docker image needs frontend assets embedded at build time:
+
+```typescript
+// 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(() => {});
+ }
+}
+```
+
+### Container Configuration
+
+**Backend container:**
+```typescript
+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:**
+```typescript
+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();
+```
+
+## CI Integration
+
+### GitHub Actions Setup
+
+```yaml
+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/
+```
+
+## Test ID Management
+
+### Finding Missing Test IDs
+
+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
+```
+
+### Adding Test IDs
+
+**Naming Convention:**
+- Use kebab-case: `data-testid="feature-action-element"`
+- Be specific: Include feature name + action + element type
+- For dynamic items: Use template literals `data-testid={\`item-delete-\${id}\`}`
+
+**Examples:**
+
+```tsx
+// ✅ GOOD: Specific button action
+
+
+// ✅ GOOD: Form input with context
+
+
+// ✅ GOOD: Table row with dynamic ID
+
+ {schema.name}
+
+
+// ✅ GOOD: Delete button in list
+}
+ onClick={() => deleteSchema(schema.name)}
+/>
+
+// ❌ BAD: Too generic
+
+
+// ❌ BAD: Using CSS classes as identifiers
+
+```
+
+**Where to Add:**
+1. **Primary actions**: Create, Save, Delete, Edit, Submit, Cancel buttons
+2. **Navigation**: Links to detail pages, breadcrumbs
+3. **Forms**: All input fields, selects, checkboxes, radio buttons
+4. **Lists/Tables**: Row identifiers, action buttons within rows
+5. **Dialogs/Modals**: Open/close buttons, form elements inside
+6. **Search/Filter**: Search inputs, filter dropdowns, clear buttons
+
+**Process:**
+1. Use Task/Explore to find missing testids in target feature
+2. Read the component file to understand context
+3. Add `data-testid` following naming convention
+4. Update tests to use new testids
+5. Run tests to verify selectors work
+
+## Analyzing Test Failures
+
+### Using MCP Playwright Agents
+
+**Check Test Status:**
+```typescript
+// 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
+```
+
+### Reading Playwright Logs
+
+**Common failure patterns and fixes:**
+
+#### 1. Element Not Found
+```
+Error: locator.click: Target closed
+Error: Timeout 30000ms exceeded waiting for locator
+```
+
+**Analysis steps:**
+1. Check if element has correct testid/role
+2. Verify element is visible (not hidden/collapsed)
+3. Check for timing issues (element loads async)
+4. Look for dynamic content that changes selector
+
+**Fix:**
+```typescript
+// ❌ 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();
+```
+
+#### 2. Selector Ambiguity
+```
+Error: strict mode violation: locator('button') resolved to 3 elements
+```
+
+**Analysis:**
+- Multiple elements match the selector
+- Need more specific selector or testid
+
+**Fix:**
+```typescript
+// ❌ 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();
+```
+
+#### 3. Timing/Race Conditions
+```
+Error: expect(locator).toHaveText()
+Expected string: "Success"
+Received string: "Loading..."
+```
+
+**Analysis:**
+- Test assertion ran before UI updated
+- Need to wait for specific state
+
+**Fix:**
+```typescript
+// ❌ 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 });
+```
+
+#### 4. Navigation Issues
+```
+Error: page.goto: net::ERR_CONNECTION_REFUSED
+```
+
+**Analysis:**
+- Backend/frontend not running
+- Wrong URL or port
+
+**Fix:**
+```bash
+# Check containers are running
+docker ps | grep console-backend
+
+# Check container logs
+docker logs
+
+# Verify port mapping
+curl http://localhost:3000
+
+# Check testcontainer state file
+cat .testcontainers-state.json
+```
+
+### Systematic Failure Analysis Workflow
+
+**When tests fail:**
+
+1. **Get Test Results**
+ ```
+ Use mcp__playwright-test__test_run or check console output
+ Identify which tests failed and error messages
+ ```
+
+2. **Analyze Error Patterns**
+ - Selector not found → Missing/wrong testid or element not visible
+ - Strict mode violation → Need more specific selector
+ - Timeout → Element loads async, need waitFor
+ - Connection refused → Container/service not running
+
+3. **Find Missing Test IDs**
+ ```
+ Use Task tool with Explore agent to find missing testids in the
+ components related to failed tests
+ ```
+
+4. **Add Test IDs**
+ - Read component file
+ - Add `data-testid` to problematic elements
+ - Follow naming convention
+ - Format with biome
+
+5. **Update Tests**
+ - Replace brittle selectors with stable testids
+ - Add proper wait conditions
+ - Verify with test run
+
+6. **Verify Fixes**
+ ```
+ Run specific test file to verify fix
+ Run full suite to ensure no regressions
+ ```
+
+## Troubleshooting
+
+### Container Fails to Start
+
+```bash
+# Check if frontend build exists
+ls frontend/build/
+
+# Check if Docker image built successfully
+docker images | grep console-backend
+
+# Check container logs
+docker logs
+
+# Verify Docker network
+docker network ls | grep testcontainers
+```
+
+### Test Timeout Issues
+
+```typescript
+// 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 });
+});
+```
+
+### Port Already in Use
+
+```bash
+# Find and kill process using port 3000
+lsof -ti:3000 | xargs kill -9
+
+# Or use different ports in test config
+```
+
+## Quick Reference
+
+**Test types:**
+- E2E tests (`*.spec.ts`): Complete user workflows, browser interactions
+- Integration tests (`*.test.tsx`): Component + API, no browser
+- Unit tests (`*.test.ts`): Pure logic, utilities
+
+**Commands:**
+```bash
+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:**
+1. `getByRole()` - Best for accessibility
+2. `getByLabel()` - For form inputs
+3. `getByText()` - For content verification
+4. `getByTestId()` - When semantic selectors aren't clear
+5. CSS selectors - Avoid if possible
+
+**Wait strategies:**
+- `waitForURL()` - Navigation complete
+- `waitForResponse()` - API call finished
+- `waitFor()` with `expect()` - Element state changed
+- Never use fixed `waitForTimeout()` unless absolutely necessary
+
+## Output
+
+After completing work:
+
+1. Confirm frontend build succeeded
+2. Verify all E2E tests pass
+3. Note any new test IDs added to components
+4. Mention cleanup of test containers
+5. Report test execution time and coverage
\ No newline at end of file
diff --git a/frontend/.mcp.json b/frontend/.mcp.json
new file mode 100644
index 0000000000..d9dfc1253b
--- /dev/null
+++ b/frontend/.mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "playwright-test": {
+ "command": "npx",
+ "args": [
+ "playwright",
+ "run-test-mcp-server"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
index 78b271e19c..52e2b56bd1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,6 +18,7 @@
"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",
"test:ci": "bun run test:unit && bun run test:integration",
"test": "bun run test:ci",
"test:unit": "TZ=GMT vitest run --config=vitest.config.unit.mts",
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index 6b66984c81..7334e24ecd 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -52,6 +52,8 @@ export default defineConfig({
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/specs/README.md b/frontend/specs/README.md
new file mode 100644
index 0000000000..6a06f0ee78
--- /dev/null
+++ b/frontend/specs/README.md
@@ -0,0 +1,5 @@
+# Test Plans
+
+This directory contains markdown test plans created by the Playwright planner agent.
+
+Each file represents a comprehensive test plan for a specific feature or user flow.
diff --git a/frontend/specs/topics.md b/frontend/specs/topics.md
new file mode 100644
index 0000000000..c3f1beda81
--- /dev/null
+++ b/frontend/specs/topics.md
@@ -0,0 +1,455 @@
+# Topics E2E Test Plan
+
+## Overview
+
+E2E tests for the Topics page in Redpanda Console, covering topic management, message operations, and configuration viewing.
+
+**Total Tests:** 40 tests across 6 spec files
+**Seed:** `tests/seed.spec.ts`
+
+## Routes
+
+- `/topics` - Topic list page
+- `/topics/:topicName` - Topic details page with tabs
+- `/topics/:topicName/produce-record` - Produce message page
+
+## Test Files
+
+### 1. Topic List - Basic Operations (`topic-list.spec.ts`)
+
+**6 tests** covering list viewing, search, and filtering operations.
+
+#### 1.1 View Topics List
+- Navigate to `/topics`
+- Verify page elements visible:
+ - Create topic button (testId: `create-topic-button`)
+ - Search input (testId: `search-field-input`)
+ - Show internal topics checkbox (testId: `show-internal-topics-checkbox`)
+ - Topics table (testId: `topics-table`)
+
+#### 1.2 Search Topics - Exact Match
+- Create test topic with unique name
+- Search for the exact topic name
+- Verify topic is visible in filtered results
+- Search for non-existent topic
+- Verify original topic is hidden
+
+#### 1.3 Search Topics - Regex Pattern
+- Create 3 topics: 2 with same prefix, 1 different
+- Apply regex pattern matching prefix (e.g., `^regex-test.*`)
+- Verify matching topics visible
+- Verify non-matching topic hidden
+
+#### 1.4 Clear Search Filter
+- Create test topic
+- Apply search filter that hides the topic
+- Clear the search input
+- Verify topic becomes visible again
+
+#### 1.5 Toggle Show Internal Topics
+- Navigate to topics list
+- Uncheck "Show internal topics" checkbox
+- Verify internal topics (e.g., `_schemas`) hidden
+- Check the checkbox
+- Verify internal topics visible
+
+#### 1.6 Persist Internal Topics Setting
+- Check "Show internal topics" checkbox
+- Verify `_schemas` topic visible
+- Reload page
+- Verify setting persisted (checkbox still checked, `_schemas` visible)
+- Uncheck checkbox and reload
+- Verify setting persisted (checkbox unchecked, `_schemas` hidden)
+
+---
+
+### 2. Topic Creation (`topic-creation.spec.ts`)
+
+**7 tests** covering topic creation flows and validation.
+
+#### 2.1 Create Topic with Default Settings
+- Navigate to `/topics`
+- Click create topic button (testId: `create-topic-button`)
+- Verify modal opens, topic name field focused
+- Fill topic name (testId: `topic-name`)
+- Click create button (testId: `onOk-button`)
+- Close success modal (testId: `create-topic-success__close-button`)
+- Verify topic appears in list (testId: `topic-link-{topicName}`)
+
+#### 2.2 Create Topic with Custom Configuration
+- Open create topic modal
+- Fill topic name
+- Set partitions to 6 (placeholder: `/partitions/i`)
+- Click create
+- Close success modal
+- Navigate to topic configuration tab
+- Verify configuration page loads (testId: `config-group-table`)
+
+#### 2.3 Validate Empty Topic Name
+- Open create topic modal
+- Leave topic name empty
+- Verify create button disabled (testId: `onOk-button`)
+- Click cancel to close modal
+
+#### 2.4 Validate Invalid Topic Name Characters
+- Open create topic modal
+- Enter invalid topic name with spaces and special chars
+- If button enabled, click shows validation error
+- If button disabled, verify it's disabled
+- Modal remains open
+
+#### 2.5 Validate Replication Factor
+- Open create topic modal
+- Enter topic name
+- Try setting replication factor to 999 (placeholder: `/replication/i`)
+- Verify validation error appears (if field is enabled)
+- Cancel modal
+
+#### 2.6 Cancel Topic Creation
+- Open create topic modal
+- Fill some values
+- Click Cancel button
+- Verify modal closes
+- Verify topic not created
+
+#### 2.7 Create and Verify in Multiple Views
+- Create topic through modal
+- Verify in topics list
+- Navigate to topic details
+- Verify URL matches `/topics/{topicName}`
+- Verify topic name displayed
+- Navigate back to list
+- Verify topic still visible
+
+---
+
+### 3. Topic Details - Navigation and Tabs (`topic-navigation.spec.ts`)
+
+**5 tests** covering topic details navigation and tab functionality.
+
+#### 3.1 Navigate to Topic Details
+- Create test topic
+- Navigate to topics list
+- Click topic link (testId: `topic-link-{topicName}`)
+- Verify URL changes to `/topics/{topicName}`
+- Verify topic name displayed
+- Verify tabs visible (role: `tablist`)
+
+#### 3.2 View Messages Tab (Default)
+- Create test topic
+- Click topic link
+- Verify tablist visible
+- Verify Messages tab content visible
+- Verify message-related elements present
+
+#### 3.3 Navigate to Tab via URL Hash
+- Create test topic
+- Navigate directly to `/topics/{topicName}#configuration`
+- Verify configuration tab active (testId: `config-group-table`)
+- Navigate to `/topics/{topicName}#partitions`
+- Verify partitions content visible
+
+#### 3.4 View Configuration Tab with Grouped Settings
+- Create test topic
+- Navigate to configuration tab
+- Verify config groups visible in expected order:
+ - Retention, Compaction, Replication, Tiered Storage
+ - Write Caching, Iceberg, Schema Registry and Validation
+ - Message Handling, Compression, Storage Internals
+- Verify at least "Retention" group present
+- Verify groups maintain order
+
+#### 3.5 Navigate Back via Breadcrumb
+- Create test topic
+- Navigate to topic details
+- Click "Topics" breadcrumb link
+- Verify returns to `/topics` (with optional trailing slash and query params)
+- Verify topic visible in list
+
+---
+
+### 4. Produce Messages (`topic-messages-production.spec.ts`)
+
+**7 tests** covering message production operations.
+
+#### 4.1 Produce Simple Text Message
+- Create test topic
+- Produce message via helper
+- Navigate to messages tab
+- Verify message content visible
+
+#### 4.2 Produce Message with Key *(skipped)*
+- Create test topic
+- Navigate to produce page
+- Fill key editor (testId: `produce-key-editor`)
+- Fill value editor (testId: `produce-value-editor`)
+- Click produce button (testId: `produce-button`)
+- Verify message produced
+
+#### 4.3 Produce Multiple Messages in Sequence
+- Create test topic
+- Produce 3 messages sequentially via UI
+- Each message: navigate to produce, fill editor, click produce
+- Verify each message appears after production
+- Navigate to messages tab
+- Verify all 3 messages visible
+
+#### 4.4 Produce Large Message
+- Create test topic
+- Navigate to produce page
+- Generate 30KB+ content
+- Paste into value editor via clipboard
+- Click produce
+- Verify "Message size exceeds display limit" warning
+- Expand message row
+- Verify warning about performance degradation
+- Click "Load anyway" button (testId: `load-anyway-button`)
+- Verify payload content visible (testId: `payload-content`)
+
+#### 4.5 Navigate to Produce Page
+- Create test topic
+- Navigate to produce page
+- Verify produce button visible (testId: `produce-button`)
+- Verify value editor visible (testId: `produce-value-editor`)
+- Verify key editor visible (testId: `produce-key-editor`)
+- Verify heading indicates produce/publish
+
+#### 4.6 Handle Empty Message Production
+- Create test topic
+- Navigate to produce page
+- Click in value editor but don't enter text
+- Click produce button
+- Verify no crash occurs (wait 2 seconds)
+
+#### 4.7 Clear Editor Between Produces
+- Create test topic
+- Navigate to produce page, produce first message
+- Navigate to produce page again
+- Clear editor (Ctrl+A or Meta+A, then Backspace)
+- Produce second message
+- Navigate to messages tab
+- Verify both messages exist
+
+---
+
+### 5. View and Filter Messages (`topic-messages-filtering.spec.ts`)
+
+**8 tests** covering message viewing and filtering operations.
+
+#### 5.1 Expand Message to View Details
+- Create topic and produce message
+- Navigate to messages tab
+- Verify message content visible
+- Click expand button (label: "Collapse row")
+- Verify expanded details visible (testId: `payload-content`)
+- Verify metadata visible (Offset/Partition/Timestamp)
+
+#### 5.2 Search Message Content
+- Create topic and produce 2 messages (one with keyword, one without)
+- Navigate to messages tab
+- Find search input (placeholder: `/search|filter/i`)
+- Enter search term and press Enter
+- Verify matching message visible
+- (Behavior depends on implementation for non-matching)
+
+#### 5.3 Filter Messages by Partition *(skipped)*
+- Create topic and produce message
+- Look for partition filter dropdown
+- Select partition 0
+- Verify messages filter to selected partition
+
+#### 5.4 Filter Messages by Offset
+- Create topic and produce 3 messages
+- Navigate to messages tab
+- Find offset input (placeholder: `/offset/i`)
+- Set start offset to 1 (skip first message)
+- Press Enter and wait
+- Verify filtered messages visible
+
+#### 5.5 Clear All Filters
+- Create topic and produce message
+- Apply search filter
+- Look for clear/reset button (role: `button`, name: `/clear|reset/i`)
+- Click clear button
+- Verify message becomes visible again
+
+#### 5.6 Handle Empty Topic
+- Create empty topic (no messages)
+- Navigate to messages tab
+- Verify empty state message visible (text: `/No messages|empty/i`)
+- Verify produce button still available
+
+#### 5.7 Handle Rapid Filter Changes
+- Create topic and produce message
+- Navigate to messages tab
+- Rapidly change search terms multiple times
+- Clear and enter final search term
+- Verify handles gracefully without errors
+- Verify message displays correctly
+
+#### 5.8 Preserve Filters in URL Parameters
+- Create topic and produce message
+- Navigate to messages tab
+- Enter search term in quick search and press Enter
+- Verify URL contains filter parameter (e.g., `q=test-search`)
+- Reload page
+- Verify URL still contains parameter
+- Verify search input has the value
+- (Uses testId: `message-quick-search-input`)
+
+---
+
+### 6. Message Actions and Export (`topic-messages-actions.spec.ts`)
+
+**7 tests** covering message actions like copy, export, and viewing metadata.
+
+**Note:** Tests use `permissions: ['clipboard-write', 'clipboard-read']`
+
+#### 6.1 Copy Message Value to Clipboard
+- Create topic and produce message
+- Navigate to messages tab
+- Expand first message
+- Click copy value button (role: `button`, name: `/copy value/i`)
+- Verify clipboard content matches message value
+- Verify success toast visible: "Value copied to clipboard"
+
+#### 6.2 Export Single Message as JSON
+- Create topic and produce message
+- Navigate to messages tab
+- Expand first message
+- Click "Download Record"
+- JSON format selected by default
+- Click save in dialog (role: `dialog`, name: `/save message/i`)
+- Verify download with `.json` extension
+- Save and verify file content contains message
+
+#### 6.3 Export Single Message as CSV
+- Create topic and produce message
+- Navigate to messages tab
+- Expand first message
+- Click "Download Record"
+- Select CSV format (testId: `csv_field`)
+- Click "Save Messages" in dialog
+- Verify download as `messages.csv`
+- Verify file content contains message
+
+#### 6.4 Export Message with Special Characters
+- Create topic and produce message with special chars (quotes, commas, emojis)
+- Navigate to messages tab
+- Expand message and export as JSON
+- Verify special characters preserved in file
+
+#### 6.5 Open and Cancel Export Dialog
+- Create topic and produce message
+- Navigate to messages tab
+- Expand message
+- Click "Download Record"
+- Verify dialog opens (role: `dialog`, name: `/save message/i`)
+- Click Cancel button
+- Verify dialog closes
+
+#### 6.6 Handle Large Payload Export
+- Create topic
+- Navigate to produce page and create 30KB+ message
+- Verify "Message size exceeds display limit" warning
+- Expand message row
+- Click "Load anyway" button (testId: `load-anyway-button`)
+- Export message as JSON
+- Verify file size > 1KB
+- Verify payload content loads
+
+#### 6.7 View Message Metadata
+- Create topic and produce message
+- Navigate to messages tab
+- Expand first message
+- Verify metadata visible:
+ - Offset/offset (case insensitive)
+ - Partition/partition
+ - Timestamp/timestamp
+- Verify payload content visible (testId: `payload-content`)
+
+---
+
+## Implementation Details
+
+### Test Utilities
+
+All tests use the `TopicPage` Page Object Model:
+
+```typescript
+import { TopicPage } from '../utils/TopicPage';
+
+const topicPage = new TopicPage(page);
+
+// High-level operations
+await topicPage.createTopic(topicName);
+await topicPage.deleteTopic(topicName);
+await topicPage.produceMessage(topicName, message);
+
+// Navigation
+await topicPage.goToTopicsList();
+await topicPage.goToTopicDetails(topicName);
+await topicPage.goToProduceRecord(topicName);
+
+// List operations
+await topicPage.searchTopics(searchTerm);
+await topicPage.toggleInternalTopics(checked);
+await topicPage.verifyTopicInList(topicName);
+```
+
+### Test IDs Used
+
+**Topic List:**
+- `create-topic-button` - Create topic button
+- `search-field-input` - Search input field
+- `show-internal-topics-checkbox` - Internal topics toggle
+- `topics-table` - Topics data table
+- `topic-link-{topicName}` - Dynamic topic link
+- `delete-topic-button-{topicName}` - Delete button per topic
+- `delete-topic-confirm-button` - Confirm delete button
+
+**Topic Creation:**
+- `topic-name` - Topic name input field
+- `onOk-button` - Create/submit button
+- `create-topic-success__close-button` - Close success modal
+
+**Topic Details:**
+- `config-group-table` - Configuration groups table
+- `produce-record-button` - Produce button in details
+- Use `role='tablist'` for tabs
+
+**Produce Messages:**
+- `produce-button` - Produce message button
+- `produce-value-editor` - Value editor (Monaco)
+- `produce-key-editor` - Key editor (Monaco)
+- `load-anyway-button` - Load large message button
+- `payload-content` - Message payload display
+
+**Messages Tab:**
+- `message-quick-search-input` - Quick search input
+- `data-table-cell` - Table cells
+- Use `aria-label="Collapse row"` for expand buttons
+
+**Export:**
+- `csv_field` - CSV format selection
+- Use `role='dialog'` with `name=/save message/i` for export dialog
+
+### Cleanup
+
+All tests use `TopicPage.deleteTopic()` in teardown to clean up created topics.
+
+### Skipped Tests
+
+Some tests are marked as `test.skip()`:
+- Produce message with key - Needs stable key editor interaction
+- Filter messages by partition - Needs better select control handling
+
+### Notes
+
+- Tests create topics with unique names using `Date.now()` timestamps
+- Tests verify both UI state and data consistency
+- URL parameter preservation is tested for search filters
+- Internal topics (`_schemas`) are used to test visibility toggle
+- Large message tests use 30KB+ content to trigger size warnings
+- Special characters testing includes quotes, commas, and emojis
+- All tests are self-contained with setup and teardown
diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx
index cab9dd65a9..3fe67b4ce5 100644
--- a/frontend/src/components/layout/header.tsx
+++ b/frontend/src/components/layout/header.tsx
@@ -70,6 +70,7 @@ const AppPageHeader = observer(() => {
fontSize="xl"
fontWeight={700}
mr={2}
+ role="heading"
{...(lastBreadcrumb.options?.canBeTruncated
? {
wordBreak: 'break-all',
diff --git a/frontend/src/components/pages/admin/admin-debug-bundle-progress.tsx b/frontend/src/components/pages/admin/admin-debug-bundle-progress.tsx
index 5a9e799459..87afa2ac44 100644
--- a/frontend/src/components/pages/admin/admin-debug-bundle-progress.tsx
+++ b/frontend/src/components/pages/admin/admin-debug-bundle-progress.tsx
@@ -51,19 +51,23 @@ export default class AdminPageDebugBundleProgress extends PageComponent {
render() {
return (
-
+
Collect environment data that can help debug and diagnose issues with a Redpanda cluster, a broker, or the
- machine it’s running on. This will bundle the collected data into a ZIP file.
+ machine it's running on. This will bundle the collected data into a ZIP file.
- {api.isDebugBundleInProgress && Generating bundle...}
+ {api.isDebugBundleInProgress && Generating bundle...}
{api.isDebugBundleExpired && (
- Your previous bundle has expired and cannot be downloaded.
+
+ Your previous bundle has expired and cannot be downloaded.
+
+ )}
+ {api.isDebugBundleError && (
+ Your debug bundle was not generated.
)}
- {api.isDebugBundleError && Your debug bundle was not generated.}
{api.canDownloadDebugBundle && (
-
+
Debug bundle complete:
@@ -79,6 +83,7 @@ export default class AdminPageDebugBundleProgress extends PageComponent {
{api.isDebugBundleInProgress ? (
) : (
-
- );
+ ),
},
- size: Number.POSITIVE_INFINITY,
- },
- {
- header: 'Partitions',
- accessorKey: 'partitionCount',
- enableResizing: true,
- cell: ({ row: { original: topic } }) => topic.partitionCount,
- },
- {
- header: 'Replicas',
- accessorKey: 'replicationFactor',
- },
- {
- header: 'CleanupPolicy',
- accessorKey: 'cleanupPolicy',
- },
- {
- header: 'Size',
- accessorKey: 'logDirSummary.totalSizeBytes',
- cell: ({ row: { original: topic } }) => renderLogDirSummary(topic.logDirSummary),
- },
- {
- id: 'action',
- header: '',
- cell: ({ row: { original: record } }) => (
-
-
- {
- event.stopPropagation();
- onDelete(record);
- }}
- type="button"
- >
-
-
-
-
- ),
- },
- ]}
- data={topics}
- onPaginationChange={onPaginationChange(paginationParams, ({ pageSize, pageIndex }) => {
- uiSettings.topicList.pageSize = pageSize;
- editQuery((query) => {
- query.page = String(pageIndex);
- query.pageSize = String(pageSize);
- });
- })}
- pagination={paginationParams}
- sorting={true}
- />
+ ]}
+ data={topics}
+ onPaginationChange={onPaginationChange(paginationParams, ({ pageSize, pageIndex }) => {
+ uiSettings.topicList.pageSize = pageSize;
+ editQuery((query) => {
+ query.page = String(pageIndex);
+ query.pageSize = String(pageSize);
+ });
+ })}
+ pagination={paginationParams}
+ sorting={true}
+ />
+
);
};
diff --git a/frontend/src/utils/create-auto-modal.tsx b/frontend/src/utils/create-auto-modal.tsx
index adc0631611..4d7e718027 100644
--- a/frontend/src/utils/create-auto-modal.tsx
+++ b/frontend/src/utils/create-auto-modal.tsx
@@ -161,6 +161,7 @@ export default function createAutoModal(options: {
{response}
{
state.modalProps?.afterClose?.();
}}
diff --git a/frontend/tests/README.md b/frontend/tests/README.md
index a06991d315..b25a276fa3 100644
--- a/frontend/tests/README.md
+++ b/frontend/tests/README.md
@@ -2,8 +2,55 @@
This directory contains the E2E test setup that uses Docker containers and Playwright's lifecycle hooks to provide a complete test environment.
+## Playwright Test Agents
+
+This project uses Playwright's test agents for AI-assisted test creation and maintenance:
+
+- **Planner Agent** - Explores the application and creates detailed test plans in `specs/`
+- **Generator Agent** - Converts test plans into executable Playwright tests
+- **Healer Agent** - Automatically debugs and fixes failing tests
+
+### Test Plans
+
+Test plans are stored in the `specs/` directory as markdown files. Each plan contains:
+- Application overview and features
+- Detailed test scenarios with step-by-step instructions
+- Expected results and success criteria
+- Reference to seed test for environment setup
+
+**Available test plans:**
+- `specs/topics.md` - Comprehensive test plan for Topics page (78 scenarios)
+
+### Using Test Agents
+
+To use the test agents, describe what you need in natural language:
+
+```bash
+# Create a test plan
+"Create a test plan for the /connectors page"
+
+# Generate tests from a plan
+"Generate tests from specs/topics.md"
+
+# Fix failing tests
+"Fix the failing tests in tests/console/topic.spec.ts"
+```
+
+The agents run autonomously and will create test files in the `tests/` directory.
+
+### Seed Test
+
+The `tests/seed.spec.ts` file establishes the baseline environment for test agents:
+- Navigates to application homepage
+- Verifies basic application functionality
+- Provides context for test planning and generation
+
+Agents reference this seed test to understand your application's setup.
+
## Quick Start
+### Run All Tests (Full Automation)
+
Simply run the E2E tests as usual:
```bash
@@ -17,6 +64,33 @@ Playwright's globalSetup will automatically:
4. Run the Playwright tests
5. Clean up everything after tests complete (via globalTeardown)
+### Run Individual Tests (Development Workflow)
+
+For faster development iterations, set up the environment once and run specific tests:
+
+```bash
+# 1. Start all services (Docker, backend, frontend)
+bun run e2e-test:setup
+
+# 2. Run individual tests without global setup/teardown
+npx playwright test tests/console/topic-list.spec.ts --config playwright.config.ts
+
+# Or run specific test
+npx playwright test tests/console/topic-list.spec.ts:21 --config playwright.config.ts
+
+# Or use UI mode for debugging
+npx playwright test tests/console/topic-list.spec.ts --ui --config playwright.config.ts
+
+# 3. When done, clean up everything
+bun run e2e-test:teardown
+```
+
+**Benefits:**
+- ⚡ Faster test iterations (no 30-40s setup per run)
+- 🔍 Better for debugging specific tests
+- 🎯 Run only the tests you're working on
+- 🖥️ Keep services running between test runs
+
## Services
The following services are automatically started:
@@ -73,11 +147,39 @@ cd tests && node -e "import('./global-teardown.mjs').then(m => m.default())"
### Check status
```bash
+# Check Docker containers
docker ps | grep -E "redpanda|connect|owlshop"
-lsof -i :9090 # Backend
-lsof -i :3000 # Frontend
+
+# Check backend (Go server on port 9090)
+lsof -i :9090
+
+# Check frontend (Bun/Rsbuild on port 3000)
+lsof -i :3000
+
+# Check if services are responding
+curl http://localhost:9090/api/cluster/overview # Backend API
+curl http://localhost:3000 # Frontend
```
+### Development Tips
+
+When using `e2e-test:setup` for development:
+
+1. **Keep services running** - No need to tear down between test runs
+2. **Watch logs** - Terminal shows backend/frontend logs for debugging
+3. **Restart services** - If something breaks, run teardown then setup again
+4. **Check state files** - Process IDs stored in `tests/state/`
+5. **Manual cleanup** - If teardown fails, use:
+ ```bash
+ # Stop processes
+ pkill -f "go run"
+ pkill -f "bun.*start"
+
+ # Stop containers
+ docker stop redpanda owlshop kafka-connect 2>/dev/null
+ docker rm redpanda owlshop kafka-connect 2>/dev/null
+ ```
+
## Troubleshooting
### Containers not starting
diff --git a/frontend/tests/connector.utils.ts b/frontend/tests/connector.utils.ts
index cc5a3af8b1..f61a277d7f 100644
--- a/frontend/tests/connector.utils.ts
+++ b/frontend/tests/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/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/console-enterprise/acl.spec.ts b/frontend/tests/console-enterprise/acl.spec.ts
index 52b6be42e1..d6d13cfb45 100644
--- a/frontend/tests/console-enterprise/acl.spec.ts
+++ b/frontend/tests/console-enterprise/acl.spec.ts
@@ -16,8 +16,8 @@ import {
ResourceTypeTransactionalId,
type Rule,
} from '../../src/components/pages/acls/new-acl/acl.model';
-import { ACLPage } from '../console/pages/ACLPage';
-import { RolePage } from '../console/pages/RolePage';
+import { ACLPage } from '../console/utils/ACLPage';
+import { RolePage } from '../console/utils/RolePage';
/**
* Generates a unique principal name for testing
diff --git a/frontend/tests/console-enterprise/debug-bundle/debug-bundle.spec.ts b/frontend/tests/console-enterprise/debug-bundle/debug-bundle.spec.ts
new file mode 100644
index 0000000000..0bbb71d240
--- /dev/null
+++ b/frontend/tests/console-enterprise/debug-bundle/debug-bundle.spec.ts
@@ -0,0 +1,285 @@
+/** biome-ignore-all lint/performance/useTopLevelRegex: this is a test */
+import { expect, test } from '@playwright/test';
+
+import { DebugBundlePage } from '../../console/utils/DebugBundlePage';
+
+/**
+ * Debug Bundle E2E Tests
+ *
+ * Tests the complete debug bundle workflow including:
+ * - Navigation and page display
+ * - Basic bundle creation
+ * - Advanced bundle configuration
+ * - Progress monitoring
+ * - Bundle download and deletion
+ * - Cancellation workflow
+ */
+test.describe('Debug Bundle - Navigation and Display', () => {
+ test('should display debug bundle page with header, generate button, and no in-progress bundle', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+
+ // Verify navigation
+ await expect(page).toHaveURL('/debug-bundle');
+
+ // Verify basic mode is active (generate button visible)
+ await debugBundlePage.verifyBasicModeActive();
+
+ // Verify there is no in-progress bundle
+ const progressLink = page.getByRole('link', { name: /progress|in progress|view progress/i });
+ await expect(progressLink).not.toBeVisible();
+ });
+});
+
+test.describe('Debug Bundle - Form Mode Switching', () => {
+ test('should switch from basic to advanced mode', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+ await debugBundlePage.switchToAdvancedMode();
+ await debugBundlePage.verifyAdvancedModeActive();
+ });
+
+ test('should switch from advanced back to basic mode', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+ await debugBundlePage.switchToAdvancedMode();
+
+ // Switch back to basic
+ await debugBundlePage.switchToBasicMode();
+
+ // Verify we're back in basic mode
+ await debugBundlePage.verifyBasicModeActive();
+ });
+});
+
+test.describe('Debug Bundle - Generation Progress', () => {
+ test('should navigate to progress page after generation starts', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.generateBasicBundle();
+
+ // Should navigate to progress page
+ await expect(page).toHaveURL(/\/debug-bundle\/progress\//);
+ });
+
+ test('should display broker status during generation', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+
+ // First check if there's an existing bundle in progress
+ const hasProgress = await debugBundlePage.isBundleGenerationInProgress();
+
+ if (hasProgress) {
+ await debugBundlePage.gotoProgress();
+ await debugBundlePage.verifyGenerationInProgress();
+ await debugBundlePage.verifyBrokerStatus();
+ } else {
+ test.skip();
+ }
+ });
+
+ test('should display stop/cancel button during generation', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ const hasProgress = await debugBundlePage.isBundleGenerationInProgress();
+
+ if (hasProgress) {
+ await debugBundlePage.gotoProgress();
+
+ const stopButton = page.getByTestId('debug-bundle-stop-button');
+ await expect(stopButton).toBeVisible();
+ } else {
+ test.skip();
+ }
+ });
+
+ test('should show generation status per broker', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ const hasProgress = await debugBundlePage.isBundleGenerationInProgress();
+
+ if (hasProgress) {
+ await debugBundlePage.gotoProgress();
+ await debugBundlePage.verifyBrokerStatus();
+
+ // Verify we have broker statuses
+ const brokerStatuses = page.locator('[data-testid^="debug-bundle-broker-status-"]');
+ const count = await brokerStatuses.count();
+ expect(count).toBeGreaterThan(0);
+ } else {
+ test.skip();
+ }
+ });
+});
+
+test.describe('Debug Bundle - Download and Deletion', () => {
+ test('should display download link when bundle is ready', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+
+ // Check if there's a download link available
+ const downloadLink = page.getByRole('link', { name: /download/i });
+ const hasDownload = await downloadLink.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasDownload) {
+ await expect(downloadLink).toBeVisible();
+ await expect(downloadLink).toHaveAttribute('href', /\/api\/debug_bundle\/files\//);
+ } else {
+ test.skip();
+ }
+ });
+
+ test('should download bundle with correct filename', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+
+ const downloadLink = page.getByRole('link', { name: /download/i });
+ const hasDownload = await downloadLink.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasDownload) {
+ const download = await debugBundlePage.downloadBundle();
+ expect(download.suggestedFilename()).toMatch(/debug-bundle\.zip/);
+ } else {
+ test.skip();
+ }
+ });
+
+ test('should display delete button for existing bundle', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+
+ // Look for delete button (might be icon button)
+ const deleteButton = page.getByRole('button', { name: /delete/i }).or(page.locator('button[aria-label*="delete"]'));
+
+ const hasDelete = await deleteButton.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasDelete) {
+ await expect(deleteButton).toBeVisible();
+ } else {
+ test.skip();
+ }
+ });
+});
+
+test.describe('Debug Bundle - Error Handling', () => {
+ test('should display error message if bundle generation fails', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+
+ // Check for error indicators using testId
+ const errorText = page.getByTestId('debug-bundle-error-text');
+ const hasError = await errorText.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasError) {
+ await debugBundlePage.verifyGenerationFailed();
+ } else {
+ test.skip();
+ }
+ });
+
+ test('should allow retry after failure', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+
+ // Look for try again button using testId
+ const retryButton = page.getByTestId('debug-bundle-try-again-button');
+ const hasRetry = await retryButton.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasRetry) {
+ await expect(retryButton).toBeVisible();
+ await expect(retryButton).toHaveText('Try again');
+ } else {
+ test.skip();
+ }
+ });
+});
+
+test.describe('Debug Bundle - Permissions and Access', () => {
+ test('should show debug bundle link in header if user has permissions', async ({ page }) => {
+ await page.goto('/');
+
+ // Look for debug bundle link in navigation
+ const debugBundleLink = page.getByRole('link', { name: /debug bundle/i });
+ const hasLink = await debugBundleLink.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasLink) {
+ await expect(debugBundleLink).toBeVisible();
+ }
+ });
+
+ test('should navigate to debug bundle from cluster health overview', async ({ page }) => {
+ await page.goto('/overview');
+
+ // Look for debug bundle link in overview
+ const debugBundleLink = page.getByRole('link', { name: /debug bundle|generate.*bundle/i });
+ const hasLink = await debugBundleLink.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasLink) {
+ await expect(debugBundleLink).toBeVisible();
+ await debugBundleLink.click();
+ await expect(page).toHaveURL(/\/debug-bundle/);
+ }
+ });
+});
+
+test.describe('Debug Bundle - Bundle Expiration', () => {
+ test('should display expiration indicator for expired bundles', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+
+ // Look for expired status using testId
+ const expiredText = page.getByTestId('debug-bundle-expired-text');
+ const hasExpired = await expiredText.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasExpired) {
+ await debugBundlePage.verifyBundleExpired();
+ } else {
+ test.skip();
+ }
+ });
+});
+
+// biome-ignore lint/suspicious/noSkippedTests:
+test.skip('Debug Bundle - Confirmation Dialogs', () => {
+ test('should show confirmation dialog when generating bundle if one exists', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+
+ await debugBundlePage.generate();
+
+ // Check if confirmation dialog appears
+ const confirmDialog = page.getByRole('dialog').or(page.getByText(/are you sure|confirm|replace/i));
+ const hasConfirm = await confirmDialog.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasConfirm) {
+ // Verify the confirmation message is visible
+ await expect(
+ page.getByText(
+ 'You have an existing debug bundle; generating a new one will delete the previous one. Are you sure?'
+ )
+ ).toBeVisible();
+
+ // Cancel to avoid actually generating
+ const cancelButton = page.getByRole('button', { name: /cancel|no/i });
+ await cancelButton.click();
+
+ // Should still be on main page
+ await expect(page).toHaveURL('/debug-bundle');
+ }
+ });
+});
+
+test.describe('Debug Bundle - SCRAM Authentication', () => {
+ test('should display SCRAM authentication fields in advanced mode', async ({ page }) => {
+ const debugBundlePage = new DebugBundlePage(page);
+ await debugBundlePage.goto();
+ await debugBundlePage.switchToAdvancedMode();
+
+ // Look for SCRAM/SASL configuration
+ const scramFields = page.getByText(/scram|sasl/i);
+ const hasScram = await scramFields
+ .first()
+ .isVisible({ timeout: 2000 })
+ .catch(() => false);
+
+ if (hasScram) {
+ await expect(scramFields.first()).toBeVisible();
+ }
+ });
+});
diff --git a/frontend/tests/console-enterprise/users.spec.ts b/frontend/tests/console-enterprise/users.spec.ts
index e1c254789c..4d0ed02d1c 100644
--- a/frontend/tests/console-enterprise/users.spec.ts
+++ b/frontend/tests/console-enterprise/users.spec.ts
@@ -1,17 +1,18 @@
import { expect, test } from '@playwright/test';
-import { createUser, deleteUser } from '../users.utils';
+import { SecurityPage } from '../console/utils/SecurityPage';
test.describe('Users', () => {
test('should create an user, check that user exists, user can be deleted', async ({ page }) => {
const username = 'user-2';
- await createUser(page, { username });
+ const securityPage = new SecurityPage(page);
+ await securityPage.createUser(username);
const userInfoEl = page.locator("text='User created successfully'");
await expect(userInfoEl).toBeVisible();
- await deleteUser(page, { username });
+ await securityPage.deleteUser(username);
});
test('should be able to search for an user with regexp', async ({ page }) => {
@@ -21,9 +22,10 @@ test.describe('Users', () => {
const userName2 = `user-${r}-regexp-2`;
const userName3 = `user-${r}-regexp-3`;
- await createUser(page, { username: userName1 });
- await createUser(page, { username: userName2 });
- await createUser(page, { username: userName3 });
+ const securityPage = new SecurityPage(page);
+ await securityPage.createUser(userName1);
+ await securityPage.createUser(userName2);
+ await securityPage.createUser(userName3);
await page.goto('/security/users/', {
waitUntil: 'domcontentloaded',
@@ -40,8 +42,8 @@ test.describe('Users', () => {
await page.getByTestId('data-table-cell').locator(`a[href='/security/users/${userName3}/details']`).count()
).toEqual(0);
- await deleteUser(page, { username: userName1 });
- await deleteUser(page, { username: userName2 });
- await deleteUser(page, { username: userName3 });
+ await securityPage.deleteUser(userName1);
+ await securityPage.deleteUser(userName2);
+ await securityPage.deleteUser(userName3);
});
});
diff --git a/frontend/tests/console/acl.spec.ts b/frontend/tests/console/acls/acl.spec.ts
similarity index 99%
rename from frontend/tests/console/acl.spec.ts
rename to frontend/tests/console/acls/acl.spec.ts
index aae7f2cf2c..9c2384dfd9 100644
--- a/frontend/tests/console/acl.spec.ts
+++ b/frontend/tests/console/acls/acl.spec.ts
@@ -16,9 +16,9 @@ import {
ResourceTypeTopic,
ResourceTypeTransactionalId,
type Rule,
-} from '../../src/components/pages/acls/new-acl/acl.model';
-import { ACLPage } from './pages/ACLPage';
-import { RolePage } from './pages/RolePage';
+} from '../../../src/components/pages/acls/new-acl/acl.model';
+import { ACLPage } from '../utils/ACLPage';
+import { RolePage } from '../utils/RolePage';
/**
* Generates a unique principal name for testing
diff --git a/frontend/tests/console/connector.spec.ts b/frontend/tests/console/connectors/connector.spec.ts
similarity index 94%
rename from frontend/tests/console/connector.spec.ts
rename to frontend/tests/console/connectors/connector.spec.ts
index bcb531bd54..687edbbd5c 100644
--- a/frontend/tests/console/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 '../../connector.utils';
// biome-ignore lint/suspicious/noExportsInTest: ignore for this test
export const ACCESS_KEY = 'accesskey';
diff --git a/frontend/tests/console/core.spec.ts b/frontend/tests/console/core.spec.ts
deleted file mode 100644
index a490297847..0000000000
--- a/frontend/tests/console/core.spec.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { expect, test } from '@playwright/test';
-
-// Basic tests to help us setup a CI pipeline
-test.describe('Core', () => {
- test('has title', async ({ page }) => {
- await page.goto('/');
-
- await expect(page).toHaveTitle(/Redpanda/);
- });
-
- test('has version title', async ({ page }) => {
- await page.goto('/');
-
- await expect(page.getByTestId('versionTitle')).toBeVisible();
- });
-});
diff --git a/frontend/tests/console/schema.spec.ts b/frontend/tests/console/schema.spec.ts
deleted file mode 100644
index deaa085e50..0000000000
--- a/frontend/tests/console/schema.spec.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { expect, test } from '@playwright/test';
-
-const SCHEMA_REGISTRY_TABLE_NAME_TESTID = 'schema-registry-table-name';
-
-/**
- * This test is depentent on a certain owlshop-data configuration.
- */
-test.describe('Schema', () => {
- test('should filter on schema ID', async ({ page }) => {
- // Let's search for 7
- await page.goto('/schema-registry');
- await page.getByPlaceholder('Filter by subject name or schema ID...').fill('7');
- await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Address').waitFor();
- expect(await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count()).toEqual(1);
-
- // Let's search for 1
- await page.getByPlaceholder('Filter by subject name or schema ID...').fill('1');
- await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Customer').waitFor();
- await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Address').waitFor();
- await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('shop/v1/address.proto').waitFor();
- await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('shop/v1/customer.proto').waitFor();
- expect(await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count()).toEqual(4);
- });
- test("should show 'Schema search help'", async ({ page }) => {
- // Let's search for 7
- await page.goto('/schema-registry');
- await page.getByTestId('schema-search-help').click();
- await page.getByTestId('schema-search-header').waitFor();
- });
- test('should filter on schema name', async ({ page }) => {
- // Let's search for 7
- await page.goto('/schema-registry');
- await page.getByPlaceholder('Filter by subject name or schema ID...').fill('com.shop.v1.avro.Address');
- await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Address').waitFor({
- timeout: 1000,
- });
- expect(await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count()).toEqual(1);
- });
- test('should filter on schema name by regexp', async ({ page }) => {
- // Let's search for 7
- await page.goto('/schema-registry');
- await page.getByPlaceholder('Filter by subject name or schema ID...').fill('com.shop.v[1-8].avro');
- await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Address').waitFor({
- timeout: 1000,
- });
- await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Customer').waitFor({
- timeout: 1000,
- });
- expect(await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count()).toEqual(2);
- });
-});
diff --git a/frontend/tests/console/schemas/schema.spec.ts b/frontend/tests/console/schemas/schema.spec.ts
new file mode 100644
index 0000000000..5f544e1a94
--- /dev/null
+++ b/frontend/tests/console/schemas/schema.spec.ts
@@ -0,0 +1,147 @@
+import { expect, test } from '@playwright/test';
+
+const SCHEMA_REGISTRY_TABLE_NAME_TESTID = 'schema-registry-table-name';
+
+/**
+ * This test is depentent on a certain owlshop-data configuration.
+ */
+test.describe('Schema - Filtering (OwlShop dependent)', () => {
+ test('should filter on schema ID', async ({ page }) => {
+ await page.goto('/schema-registry');
+ await page.getByPlaceholder('Filter by subject name or schema ID...').fill('7');
+ await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Address').waitFor();
+ expect(await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count()).toEqual(1);
+
+ await page.getByPlaceholder('Filter by subject name or schema ID...').fill('1');
+ await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Customer').waitFor();
+ await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Address').waitFor();
+ await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('shop/v1/address.proto').waitFor();
+ await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('shop/v1/customer.proto').waitFor();
+ expect(await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count()).toEqual(4);
+ });
+
+ test("should show 'Schema search help'", async ({ page }) => {
+ await page.goto('/schema-registry');
+ await page.getByTestId('schema-search-help').click();
+ await page.getByTestId('schema-search-header').waitFor();
+ });
+
+ test('should filter on schema name', async ({ page }) => {
+ await page.goto('/schema-registry');
+ await page.getByPlaceholder('Filter by subject name or schema ID...').fill('com.shop.v1.avro.Address');
+ await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Address').waitFor({
+ timeout: 1000,
+ });
+ expect(await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count()).toEqual(1);
+ });
+
+ test('should filter on schema name by regexp', async ({ page }) => {
+ await page.goto('/schema-registry');
+ await page.getByPlaceholder('Filter by subject name or schema ID...').fill('com.shop.v[1-8].avro');
+ await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Address').waitFor({
+ timeout: 1000,
+ });
+ await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).getByText('com.shop.v1.avro.Customer').waitFor({
+ timeout: 1000,
+ });
+ expect(await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count()).toEqual(2);
+ });
+});
+
+test.describe('Schema - Creation and Management', () => {
+ test('should show edit compatibility button', async ({ page }) => {
+ await page.goto('/schema-registry');
+ await expect(page.getByRole('button', { name: 'Edit compatibility' })).toBeVisible();
+ });
+
+ test('should show soft-deleted schemas when checkbox is checked', async ({ page }) => {
+ await page.goto('/schema-registry');
+
+ const checkbox = page.getByText('Show soft-deleted').locator('..');
+ await checkbox.click();
+
+ await page.waitForTimeout(500);
+ });
+
+ test('should navigate to schema details page', async ({ page }) => {
+ await page.goto('/schema-registry');
+
+ const firstSchemaLink = page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).first();
+ const schemaName = await firstSchemaLink.textContent();
+
+ await firstSchemaLink.click();
+
+ await expect(page).toHaveURL(/\/schema-registry\/subjects\//);
+ await expect(page.getByText(schemaName || '')).toBeVisible();
+ });
+});
+
+test.describe('Schema - Details and Versions', () => {
+ test('should switch between schema versions', async ({ page }) => {
+ await page.goto('/schema-registry');
+
+ const firstSchema = page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).first();
+ await firstSchema.click();
+
+ const versionSelector = page.locator('select, [role="combobox"]').first();
+ if (await versionSelector.isVisible()) {
+ await versionSelector.click();
+ }
+ });
+});
+
+test.describe('Schema - Search and Clear', () => {
+ test('should clear search filter', async ({ page }) => {
+ await page.goto('/schema-registry');
+
+ const searchInput = page.getByPlaceholder('Filter by subject name or schema ID...');
+ await searchInput.fill('test-search-term');
+
+ const initialCount = await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count();
+
+ await searchInput.clear();
+ await page.waitForTimeout(300);
+
+ const finalCount = await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count();
+ expect(finalCount).toBeGreaterThanOrEqual(initialCount);
+ });
+
+ test('should show no results message for non-existent schema', async ({ page }) => {
+ await page.goto('/schema-registry');
+
+ const searchInput = page.getByPlaceholder('Filter by subject name or schema ID...');
+ await searchInput.fill('non-existent-schema-12345-xyz');
+
+ await page.waitForTimeout(500);
+
+ const count = await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count();
+ expect(count).toEqual(0);
+ });
+});
+
+test.describe('Schema - Pagination and Sorting', () => {
+ test('should display pagination controls if many schemas exist', async ({ page }) => {
+ await page.goto('/schema-registry');
+
+ const schemaCount = await page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).count();
+
+ if (schemaCount > 20) {
+ await expect(page.locator('[aria-label="pagination"]')).toBeVisible();
+ }
+ });
+});
+
+test.describe('Schema - Navigation', () => {
+ test('should navigate back to schema list from details page', async ({ page }) => {
+ await page.goto('/schema-registry');
+
+ const firstSchema = page.getByTestId(SCHEMA_REGISTRY_TABLE_NAME_TESTID).first();
+ await firstSchema.click();
+
+ await expect(page).toHaveURL(/\/schema-registry\/subjects\//);
+
+ await page.getByRole('link', { name: 'Schema Registry' }).first().click();
+
+ await expect(page).toHaveURL('/schema-registry');
+ });
+});
diff --git a/frontend/tests/console/topic.spec.ts b/frontend/tests/console/topic.spec.ts
deleted file mode 100644
index 48d14223c2..0000000000
--- a/frontend/tests/console/topic.spec.ts
+++ /dev/null
@@ -1,328 +0,0 @@
-import { expect, test } from '@playwright/test';
-
-import { randomUUID } from 'node:crypto';
-import fs from 'node:fs';
-import { createTopic, deleteTopic, produceMessage } from '../topic.utils';
-
-test.use({
- permissions: ['clipboard-write', 'clipboard-read'],
-});
-
-test.describe('Topic', () => {
- test('should create a message that exceeds the display limit, checks that the exceed limit message appears', async ({
- page,
- }) => {
- const topicName = `too-big-message-test-${randomUUID()}`;
-
- await createTopic(page, { topicName });
- await page.goto(`/topics/${topicName}/produce-record`);
-
- // const DefaultMaxDeserializationPayloadSize = 20_480 // 20 KB
- const maxMessageSize = 30_000;
- const fillText = 'example content ';
- const content = fillText.repeat(maxMessageSize / fillText.length + 1);
-
- const monacoEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').nth(0);
- await monacoEditor.click();
- await page.evaluate(`navigator.clipboard.writeText("${content}")`);
-
- // let's paste this on both Mac + Linux. proper way in the future is to identify platform first.
- await page.keyboard.press('Control+KeyV');
- await page.keyboard.press('Meta+KeyV');
-
- await page.getByTestId('produce-button').click();
-
- await page.getByText('Message size exceeds the display limit.').waitFor({
- state: 'visible',
- timeout: 5000,
- });
-
- await page.getByTestId('data-table-cell').nth(0).getByRole('button').click();
- await page
- .getByText('Because this message size exceeds the display limit, loading it could cause performance degradation.')
- .waitFor({
- state: 'visible',
- });
-
- await page.getByTestId('load-anyway-button').click();
- await page.getByTestId('payload-content').getByText(content).waitFor({
- state: 'visible',
- });
-
- await deleteTopic(page, { topicName });
- });
-
- test('should show internal topics if the corresponding checkbox is checked', async ({ page }) => {
- await page.goto('/topics');
- await page.getByTestId('show-internal-topics-checkbox').check();
- await expect(page.getByTestId('data-table-cell').getByText('_schemas')).toBeVisible();
- });
-
- test('should hide internal topics if the corresponding checkbox is unchecked', async ({ page }) => {
- await page.goto('/topics');
- await page.getByTestId('show-internal-topics-checkbox').uncheck();
- await expect(page.getByTestId('data-table-cell').getByText('_schemas')).not.toBeVisible();
- });
-
- test('should create a topic and properly group the configurations', async ({ page }) => {
- const topicName = `test-config-topic-${Date.now()}`;
-
- await createTopic(page, { topicName });
-
- await test.step('Verify configuration grouping', async () => {
- await page.goto(`/topics/${topicName}#configuration`);
- await expect(page.getByTestId('config-group-table')).toBeVisible();
-
- // This is the full order we currently expect to see things in
- const expected = [
- 'Retention',
- 'Compaction',
- 'Replication',
- 'Tiered Storage',
- 'Write Caching',
- 'Iceberg',
- 'Schema Registry and Validation',
- 'Message Handling',
- 'Compression',
- 'Storage Internals',
- ];
-
- // Grab the actual groups on the page, then grab the intersection
- const actual = await page.locator('.configGroupTitle').allTextContents();
- const filtered = actual.filter((t) => expected.includes(t));
- const present = expected.filter((t) => actual.includes(t));
-
- // Make sure the basic options ('Retention') is present, and the rest are in the right order
- expect(filtered).toContain('Retention');
- expect(filtered).toEqual(present);
- });
-
- await deleteTopic(page, { topicName });
- });
-
- test('should create a topic, produce a message, export it as CSV format and delete a topic', async ({ page }) => {
- const topicName = `test-topic-${Date.now()}`;
-
- await createTopic(page, { topicName });
- await produceMessage(page, { topicName, message: 'hello world' });
-
- await test.step('Export message as CSV', async () => {
- await page.getByLabel('Collapse row').click();
- await page.getByText('Download Record').click();
- await page.getByTestId('csv_field').click();
- const downloadPromise = page.waitForEvent('download');
- await page.getByRole('dialog', { name: 'Save Message' }).getByRole('button', { name: 'Save Messages' }).click();
- const download = await downloadPromise;
- expect(download.suggestedFilename()).toMatch('messages.csv');
- const tempFilePath = `/tmp/downloaded-${download.suggestedFilename()}`;
- await download.saveAs(tempFilePath);
- const fileContent = fs.readFileSync(tempFilePath, 'utf-8');
- expect(fileContent).toContain('hello world');
- fs.unlinkSync(tempFilePath); // Clean up
- });
-
- await deleteTopic(page, { topicName });
- });
-
- test('should create topic, produce message, copy value to clipboard, and delete topic', async ({ page }) => {
- const topicName = `test-topic-clipboard-${Date.now()}`;
- const messageValue = 'hello clipboard test';
-
- await createTopic(page, { topicName });
- await produceMessage(page, { topicName, message: messageValue });
-
- await test.step('Copy message value to clipboard', async () => {
- await page.getByLabel('Collapse row').first().click();
- // Assuming a button with text like 'Copy value' or an aria-label containing 'Copy value'
- await page.getByRole('button', { name: /copy value/i }).click();
- });
-
- await test.step('Verify clipboard content', async () => {
- const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
- expect(clipboardText).toBe(messageValue);
-
- await expect(page.getByText('Value copied to clipboard')).toBeVisible({ timeout: 2000 });
- });
-
- await deleteTopic(page, { topicName });
- });
-
- test('should search for topics using the search input', async ({ page }) => {
- const topicName = `search-test-topic-${Date.now()}`;
-
- await page.goto('/topics');
-
- await test.step('Create a test topic', async () => {
- await page.getByTestId('create-topic-button').click();
- await page.getByTestId('topic-name').fill(topicName);
- await page.getByTestId('onOk-button').click();
- await page.getByRole('button', { name: 'Close' }).click();
- await expect(page.getByRole('link', { name: topicName })).toBeVisible();
- });
-
- await test.step('Search for the topic', async () => {
- const searchInput = page.getByPlaceholder('Enter search term/regex');
- await searchInput.fill(topicName);
- await expect(page.getByRole('link', { name: topicName })).toBeVisible();
-
- // Search for non-existent topic
- await searchInput.fill('non-existent-topic-12345');
- await expect(page.getByRole('link', { name: topicName })).not.toBeVisible();
-
- // Clear search and verify topic appears again
- await searchInput.clear();
- await expect(page.getByRole('link', { name: topicName })).toBeVisible();
- });
-
- await test.step('Cleanup', async () => {
- await page.getByTestId(`delete-topic-button-${topicName}`).click();
- await page.getByTestId('delete-topic-confirm-button').click();
- await expect(page.getByText('Topic Deleted')).toBeVisible();
- });
- });
-
- test('should create multiple topics and verify they appear in the list', async ({ page }) => {
- const topic1 = `multi-topic-test-1-${Date.now()}`;
- const topic2 = `multi-topic-test-2-${Date.now()}`;
-
- await page.goto('/topics');
-
- await test.step('Create first topic', async () => {
- await page.getByTestId('create-topic-button').click();
- await page.getByTestId('topic-name').fill(topic1);
- await page.getByTestId('onOk-button').click();
- await page.getByRole('button', { name: 'Close' }).click();
- await expect(page.getByRole('link', { name: topic1 })).toBeVisible();
- });
-
- await test.step('Create second topic', async () => {
- await page.getByTestId('create-topic-button').click();
- await page.getByTestId('topic-name').fill(topic2);
- await page.getByTestId('onOk-button').click();
- await page.getByRole('button', { name: 'Close' }).click();
- await expect(page.getByRole('link', { name: topic2 })).toBeVisible();
- });
-
- await test.step('Verify both topics are visible', async () => {
- await expect(page.getByRole('link', { name: topic1 })).toBeVisible();
- await expect(page.getByRole('link', { name: topic2 })).toBeVisible();
- });
-
- await test.step('Cleanup', async () => {
- await page.getByTestId(`delete-topic-button-${topic1}`).click();
- await page.getByTestId('delete-topic-confirm-button').click();
- await page.getByTestId(`delete-topic-button-${topic2}`).click();
- await page.getByTestId('delete-topic-confirm-button').click();
- });
- });
-
- test('should navigate to topic details and view topic information', async ({ page }) => {
- const topicName = `details-test-topic-${Date.now()}`;
-
- await page.goto('/topics');
-
- await test.step('Create test topic', async () => {
- await page.getByTestId('create-topic-button').click();
- await page.getByTestId('topic-name').fill(topicName);
- await page.getByTestId('onOk-button').click();
- await page.getByRole('button', { name: 'Close' }).click();
- await expect(page.getByRole('link', { name: topicName })).toBeVisible();
- });
-
- await test.step('Navigate to topic details', async () => {
- await page.getByRole('link', { name: topicName }).click();
- await expect(page).toHaveURL(new RegExp(`/topics/${topicName}`));
-
- // Verify we're on the topic details page
- await expect(page.getByText(topicName)).toBeVisible();
- });
-
- await test.step('Navigate back to topics list', async () => {
- await page.getByRole('link', { name: 'Topics' }).first().click();
- await expect(page).toHaveURL(/\/topics/);
- await expect(page.getByRole('link', { name: topicName })).toBeVisible();
- });
-
- await test.step('Cleanup', async () => {
- await page.getByTestId(`delete-topic-button-${topicName}`).click();
- await page.getByTestId('delete-topic-confirm-button').click();
- await expect(page.getByText('Topic Deleted')).toBeVisible();
- });
- });
-
- test('should produce multiple messages and verify they appear in the topic', async ({ page }) => {
- const topicName = `multi-message-test-${Date.now()}`;
- const messages = ['first message', 'second message', 'third message'];
-
- await page.goto('/topics');
-
- await test.step('Create test topic', async () => {
- await page.getByTestId('create-topic-button').click();
- await page.getByTestId('topic-name').fill(topicName);
- await page.getByTestId('onOk-button').click();
- await page.getByRole('button', { name: 'Close' }).click();
- await expect(page.getByRole('link', { name: topicName })).toBeVisible();
- });
-
- await test.step('Produce multiple messages', async () => {
- for (const message of messages) {
- await page.goto(`/topics/${topicName}/produce-record`);
- const valueMonacoEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
- await valueMonacoEditor.click();
- await page.keyboard.insertText(message);
- await page.getByTestId('produce-button').click();
-
- // Verify message was produced
- const messageValueCell = page.getByRole('cell', { name: new RegExp(message, 'i') }).first();
- await expect(messageValueCell).toBeVisible();
- }
- });
-
- await test.step('Verify all messages are visible in topic', async () => {
- await page.goto(`/topics/${topicName}`);
-
- for (const message of messages) {
- await expect(page.getByText(message)).toBeVisible();
- }
- });
-
- await test.step('Cleanup', async () => {
- await page.goto('/topics');
- await page.getByTestId(`delete-topic-button-${topicName}`).click();
- await page.getByTestId('delete-topic-confirm-button').click();
- await expect(page.getByText('Topic Deleted')).toBeVisible();
- });
- });
-
- test('should handle topic creation with validation errors', async ({ page }) => {
- await page.goto('/topics');
-
- await test.step('Test empty topic name validation', async () => {
- await page.getByTestId('create-topic-button').click();
-
- // The create button should be disabled when topic name is empty
- const createButton = page.getByTestId('onOk-button');
- await expect(createButton).toBeDisabled();
-
- // Modal should still be open
- await expect(page.getByTestId('topic-name')).toBeVisible();
- });
-
- await test.step('Test invalid topic name characters', async () => {
- await page.getByTestId('topic-name').fill('invalid topic name with spaces!');
-
- // Button might be enabled but should show validation error on submit
- const createButton = page.getByTestId('onOk-button');
- if (await createButton.isEnabled()) {
- await createButton.click();
- // Should either show validation error or prevent submission
- await expect(page.getByTestId('topic-name')).toBeVisible();
- }
- });
-
- await test.step('Cancel topic creation', async () => {
- await page.getByRole('button', { name: 'Cancel' }).click();
- await expect(page.getByTestId('create-topic-button')).toBeVisible();
- });
- });
-});
diff --git a/frontend/tests/console/topics/topic-creation.spec.ts b/frontend/tests/console/topics/topic-creation.spec.ts
new file mode 100644
index 0000000000..222221983a
--- /dev/null
+++ b/frontend/tests/console/topics/topic-creation.spec.ts
@@ -0,0 +1,194 @@
+// spec: specs/topics.md
+// seed: tests/seed.spec.ts
+
+import { expect, test } from '@playwright/test';
+
+import { TopicPage } from '../utils/TopicPage';
+
+test.describe('Topic Creation', () => {
+ test('should create topic with default settings', async ({ page }) => {
+ const topicName = `default-topic-${Date.now()}`;
+
+ await test.step('Open create topic modal', async () => {
+ await page.goto('/topics');
+ await page.getByTestId('create-topic-button').click();
+
+ // Modal opens with create topic form - wait for modal animation
+ await expect(page.getByTestId('topic-name')).toBeVisible({ timeout: 5000 });
+ await expect(page.getByTestId('topic-name')).toBeFocused();
+ });
+
+ await test.step('Create topic with default settings', async () => {
+ await page.getByTestId('topic-name').fill(topicName);
+ await page.getByTestId('onOk-button').click();
+
+ // Wait for success confirmation
+ await expect(page.getByTestId('create-topic-success__close-button')).toBeVisible();
+ await page.getByTestId('create-topic-success__close-button').click();
+
+ // Topic appears in list
+ await expect(page.getByTestId(`topic-link-${topicName}`)).toBeVisible();
+ });
+
+ const topicPage = new TopicPage(page);
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should create topic with custom configuration', async ({ page }) => {
+ const topicName = `custom-config-${Date.now()}`;
+
+ await page.goto('/topics');
+ await page.getByTestId('create-topic-button').click();
+
+ await test.step('Configure topic settings', async () => {
+ await page.getByTestId('topic-name').fill(topicName);
+
+ // Set custom partitions
+ const partitionsInput = page.getByPlaceholder(/partitions/i);
+ if (await partitionsInput.isVisible()) {
+ await partitionsInput.fill('6');
+ }
+
+ // Create topic
+ await page.getByTestId('onOk-button').click();
+ await page.getByTestId('create-topic-success__close-button').click();
+ });
+
+ await test.step('Verify topic was created', async () => {
+ await expect(page.getByTestId(`topic-link-${topicName}`)).toBeVisible();
+ });
+
+ await test.step('Verify configuration', async () => {
+ // Navigate to topic configuration
+ await page.getByTestId(`topic-link-${topicName}`).click();
+ await page.goto(`/topics/${topicName}#configuration`);
+
+ // Verify configuration page loads
+ await expect(page.getByTestId('config-group-table')).toBeVisible();
+ });
+
+ const topicPage = new TopicPage(page);
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should validate empty topic name', async ({ page }) => {
+ await page.goto('/topics');
+ await page.getByTestId('create-topic-button').click();
+
+ await test.step('Verify create button is disabled for empty name', async () => {
+ // Leave topic name field empty
+ await expect(page.getByTestId('topic-name')).toHaveValue('');
+
+ // Create button should be disabled
+ const createButton = page.getByTestId('onOk-button');
+ await expect(createButton).toBeDisabled();
+
+ // Modal should still be open
+ await expect(page.getByTestId('topic-name')).toBeVisible();
+ });
+
+ await test.step('Cancel creation', async () => {
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ await expect(page.getByTestId('create-topic-button')).toBeVisible();
+ });
+ });
+
+ test('should validate invalid topic name characters', async ({ page }) => {
+ await page.goto('/topics');
+ await page.getByTestId('create-topic-button').click();
+
+ await test.step('Test invalid topic name with spaces', async () => {
+ await page.getByTestId('topic-name').fill('invalid topic name with spaces!');
+
+ const createButton = page.getByTestId('onOk-button');
+
+ if (await createButton.isEnabled()) {
+ // If button is enabled, clicking should show validation error
+ await createButton.click();
+
+ // Modal should remain open (creation prevented)
+ await expect(page.getByTestId('topic-name')).toBeVisible();
+ } else {
+ // Button is disabled due to validation
+ await expect(createButton).toBeDisabled();
+ }
+ });
+
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ });
+
+ test('should validate replication factor against broker count', async ({ page }) => {
+ await page.goto('/topics');
+ await page.getByTestId('create-topic-button').click();
+
+ await test.step('Set high replication factor', async () => {
+ await page.getByTestId('topic-name').fill(`replication-test-${Date.now()}`);
+
+ // Try to set replication factor higher than broker count
+ const replicationInput = page.getByPlaceholder(/replication/i);
+ if ((await replicationInput.isVisible()) && !(await replicationInput.isDisabled())) {
+ await replicationInput.fill('999');
+
+ // Wait a moment for validation
+ await page.waitForTimeout(500);
+
+ // Check if validation error appears
+ const errorText = page.getByText(/replication factor/i);
+ if (await errorText.isVisible()) {
+ // Validation error is shown
+ await expect(errorText).toBeVisible();
+ }
+ }
+ });
+
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ });
+
+ test('should cancel topic creation', async ({ page }) => {
+ await page.goto('/topics');
+ await page.getByTestId('create-topic-button').click();
+
+ await test.step('Fill form and cancel', async () => {
+ await page.getByTestId('topic-name').fill('cancelled-topic');
+
+ // Click cancel button
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Modal closes without creating topic
+ await expect(page.getByTestId('create-topic-button')).toBeVisible();
+ });
+
+ await test.step('Verify topic was not created', async () => {
+ // Topic should not appear in list
+ await expect(page.getByTestId('topic-link-cancelled-topic')).not.toBeVisible();
+ });
+ });
+
+ test('should create topic and verify it appears in multiple views', async ({ page }) => {
+ const topicName = `multi-view-${Date.now()}`;
+
+ await page.goto('/topics');
+ await page.getByTestId('create-topic-button').click();
+ await page.getByTestId('topic-name').fill(topicName);
+ await page.getByTestId('onOk-button').click();
+ await page.getByTestId('create-topic-success__close-button').click();
+
+ await test.step('Verify in topics list', async () => {
+ await expect(page.getByTestId(`topic-link-${topicName}`)).toBeVisible();
+ });
+
+ await test.step('Navigate to topic details', async () => {
+ await page.getByTestId(`topic-link-${topicName}`).click();
+ await expect(page).toHaveURL(new RegExp(`/topics/${topicName}`));
+ await expect(page.getByText(topicName)).toBeVisible();
+ });
+
+ await test.step('Navigate back to list', async () => {
+ await page.goto('/topics');
+ await expect(page.getByTestId(`topic-link-${topicName}`)).toBeVisible();
+ });
+
+ const topicPage = new TopicPage(page);
+ await topicPage.deleteTopic(topicName);
+ });
+});
diff --git a/frontend/tests/console/topics/topic-list.spec.ts b/frontend/tests/console/topics/topic-list.spec.ts
new file mode 100644
index 0000000000..a219eecbf4
--- /dev/null
+++ b/frontend/tests/console/topics/topic-list.spec.ts
@@ -0,0 +1,135 @@
+// spec: specs/topics.md
+// seed: tests/seed.spec.ts
+
+import { expect, test } from '@playwright/test';
+
+import { TopicPage } from '../utils/TopicPage';
+
+test.describe('Topic List - Basic Operations', () => {
+ test('should view topics list with all elements visible', async ({ page }) => {
+ const topicPage = new TopicPage(page);
+ await topicPage.goToTopicsList();
+
+ // Verify page loaded and basic elements are visible
+ await expect(page.getByTestId('search-field-input')).toBeVisible();
+ await expect(page.getByTestId('show-internal-topics-checkbox')).toBeVisible();
+
+ // Verify data table is present
+ await expect(page.getByTestId('topics-table')).toBeVisible();
+ });
+
+ test('should search topics with exact match', async ({ page }) => {
+ const topicPage = new TopicPage(page);
+ const topicName = `search-exact-${Date.now()}`;
+
+ await topicPage.createTopic(topicName);
+
+ await test.step('Search for the created topic', async () => {
+ await topicPage.searchTopics(topicName);
+
+ // Topic should be visible
+ await topicPage.verifyTopicInList(topicName);
+
+ // Search for non-existent topic
+ await topicPage.searchTopics('non-existent-topic-xyz-12345');
+ await topicPage.verifyTopicNotInList(topicName);
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should search topics with regex pattern', async ({ page }) => {
+ const topicPage = new TopicPage(page);
+ const prefix = `regex-test-${Date.now()}`;
+ const topic1 = `${prefix}-alpha`;
+ const topic2 = `${prefix}-beta`;
+ const topic3 = `other-topic-${Date.now()}`;
+
+ await topicPage.createTopic(topic1);
+ await topicPage.createTopic(topic2);
+ await topicPage.createTopic(topic3);
+
+ await test.step('Search with regex pattern', async () => {
+ // Search for topics starting with prefix using regex
+ await topicPage.searchTopics(`^${prefix}.*`);
+
+ // Both matching topics should be visible
+ await topicPage.verifyTopicInList(topic1);
+ await topicPage.verifyTopicInList(topic2);
+
+ // Non-matching topic should not be visible
+ await topicPage.verifyTopicNotInList(topic3);
+ });
+
+ await topicPage.deleteTopic(topic1);
+ await topicPage.deleteTopic(topic2);
+ await topicPage.deleteTopic(topic3);
+ });
+
+ test('should clear search filter and show all topics', async ({ page }) => {
+ const topicPage = new TopicPage(page);
+ const topicName = `clear-search-${Date.now()}`;
+
+ await topicPage.createTopic(topicName);
+
+ await test.step('Apply and clear search filter', async () => {
+ // Apply search filter
+ await topicPage.searchTopics('non-matching-search-term');
+ await topicPage.verifyTopicNotInList(topicName);
+
+ // Clear search filter
+ await topicPage.clearSearch();
+
+ // Topic should be visible again
+ await topicPage.verifyTopicInList(topicName);
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should toggle show internal topics checkbox', async ({ page }) => {
+ const topicPage = new TopicPage(page);
+ await topicPage.goToTopicsList();
+
+ await test.step('Hide internal topics', async () => {
+ await topicPage.toggleInternalTopics(false);
+
+ // Internal topics (starting with _) should be hidden
+ await expect(page.getByTestId('data-table-cell').getByText('_schemas')).not.toBeVisible();
+ });
+
+ await test.step('Show internal topics', async () => {
+ await topicPage.toggleInternalTopics(true);
+
+ // Internal topics should be visible
+ await expect(page.getByTestId('data-table-cell').getByText('_schemas')).toBeVisible();
+ });
+ });
+
+ test('should persist internal topics visibility setting across page reloads', async ({ page }) => {
+ const topicPage = new TopicPage(page);
+ await topicPage.goToTopicsList();
+
+ await test.step('Set internal topics to visible', async () => {
+ await topicPage.toggleInternalTopics(true);
+ await expect(page.getByTestId('data-table-cell').getByText('_schemas')).toBeVisible();
+ });
+
+ await test.step('Reload page and verify setting persists', async () => {
+ await page.reload();
+
+ // Setting should persist - internal topics still visible
+ await expect(page.getByTestId('show-internal-topics-checkbox')).toBeChecked();
+ await expect(page.getByTestId('data-table-cell').getByText('_schemas')).toBeVisible();
+ });
+
+ await test.step('Hide internal topics and verify persistence', async () => {
+ await topicPage.toggleInternalTopics(false);
+ await page.reload();
+
+ // Setting should persist - internal topics still hidden
+ await expect(page.getByTestId('show-internal-topics-checkbox')).not.toBeChecked();
+ await expect(page.getByTestId('data-table-cell').getByText('_schemas')).not.toBeVisible();
+ });
+ });
+});
diff --git a/frontend/tests/console/topics/topic-messages-actions.spec.ts b/frontend/tests/console/topics/topic-messages-actions.spec.ts
new file mode 100644
index 0000000000..aa52d85624
--- /dev/null
+++ b/frontend/tests/console/topics/topic-messages-actions.spec.ts
@@ -0,0 +1,289 @@
+// spec: specs/topics.md
+// seed: tests/seed.spec.ts
+
+import { expect, test } from '@playwright/test';
+
+import fs from 'node:fs';
+import { TopicPage } from '../utils/TopicPage';
+
+test.use({
+ permissions: ['clipboard-write', 'clipboard-read'],
+});
+
+test.describe('Message Actions and Export', () => {
+ test('should copy message value to clipboard', async ({ page }) => {
+ const topicName = `copy-clipboard-${Date.now()}`;
+ const messageValue = 'Copy this message to clipboard';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, messageValue);
+
+ await test.step('Copy message value', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Expand message
+ await page.getByLabel('Collapse row').first().click();
+
+ // Click copy value button
+ await page.getByRole('button', { name: /copy value/i }).click();
+ });
+
+ await test.step('Verify clipboard content', async () => {
+ const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
+ expect(clipboardText).toBe(messageValue);
+
+ // Success toast should appear
+ await expect(page.getByText('Value copied to clipboard')).toBeVisible({ timeout: 2000 });
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should export single message as JSON', async ({ page }) => {
+ const topicName = `export-json-${Date.now()}`;
+ const messageValue = 'Export this message as JSON';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, messageValue);
+
+ await test.step('Export message as JSON', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Expand message
+ await page.getByLabel('Collapse row').first().click();
+
+ // Click download/export button
+ await page.getByText('Download Record').click();
+
+ // JSON format is selected by default, no need to click
+
+ // Start download
+ const downloadPromise = page.waitForEvent('download');
+ await page
+ .getByRole('dialog', { name: /save message/i })
+ .getByRole('button', { name: /save/i })
+ .click();
+
+ const download = await downloadPromise;
+
+ // Verify download
+ expect(download.suggestedFilename()).toMatch(/\.json$/);
+
+ // Save and verify content
+ const tempFilePath = `/tmp/test-${download.suggestedFilename()}`;
+ await download.saveAs(tempFilePath);
+
+ const fileContent = fs.readFileSync(tempFilePath, 'utf-8');
+ expect(fileContent).toContain(messageValue);
+
+ // Cleanup
+ fs.unlinkSync(tempFilePath);
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should export single message as CSV', async ({ page }) => {
+ const topicName = `export-csv-${Date.now()}`;
+ const messageValue = 'Export this message as CSV';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, messageValue);
+
+ await test.step('Export message as CSV', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Expand message
+ await page.getByLabel('Collapse row').first().click();
+
+ // Click download/export button
+ await page.getByText('Download Record').click();
+
+ // Select CSV format
+ await page.getByTestId('csv_field').click();
+
+ // Start download
+ const downloadPromise = page.waitForEvent('download');
+ await page
+ .getByRole('dialog', { name: /save message/i })
+ .getByRole('button', { name: /save messages/i })
+ .click();
+
+ const download = await downloadPromise;
+
+ // Verify download
+ expect(download.suggestedFilename()).toMatch('messages.csv');
+
+ // Save and verify content
+ const tempFilePath = `/tmp/test-${download.suggestedFilename()}`;
+ await download.saveAs(tempFilePath);
+
+ const fileContent = fs.readFileSync(tempFilePath, 'utf-8');
+ expect(fileContent).toContain(messageValue);
+
+ // Cleanup
+ fs.unlinkSync(tempFilePath);
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should export message with special characters', async ({ page }) => {
+ const topicName = `export-special-${Date.now()}`;
+ const messageValue = 'Message with "quotes", commas, and émojis 🎉';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, messageValue);
+
+ await test.step('Export and verify special characters', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Expand message
+ await page.getByLabel('Collapse row').first().click();
+
+ // Export as JSON
+ await page.getByText('Download Record').click();
+
+ const downloadPromise = page.waitForEvent('download');
+ await page
+ .getByRole('dialog', { name: /save message/i })
+ .getByRole('button', { name: /save/i })
+ .click();
+
+ const download = await downloadPromise;
+ const tempFilePath = `/tmp/test-special-${Date.now()}.json`;
+ await download.saveAs(tempFilePath);
+
+ const fileContent = fs.readFileSync(tempFilePath, 'utf-8');
+
+ // Verify special characters are preserved
+ expect(fileContent).toContain('quotes');
+ expect(fileContent).toContain('émojis');
+
+ // Cleanup
+ fs.unlinkSync(tempFilePath);
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should open and cancel export dialog', async ({ page }) => {
+ const topicName = `cancel-export-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, 'Message for cancel test');
+
+ await test.step('Open and cancel export', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Expand message
+ await page.getByLabel('Collapse row').first().click();
+
+ // Open export dialog
+ await page.getByText('Download Record').click();
+
+ // Verify dialog opened
+ await expect(page.getByRole('dialog', { name: /save message/i })).toBeVisible();
+
+ // Cancel export
+ const cancelButton = page.getByRole('button', { name: /cancel/i });
+ if (await cancelButton.isVisible()) {
+ await cancelButton.click();
+
+ // Dialog should close
+ await expect(page.getByRole('dialog', { name: /save message/i })).not.toBeVisible();
+ }
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should handle message with large payload export', async ({ page }) => {
+ const topicName = `large-export-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('Produce large message', async () => {
+ await page.goto(`/topics/${topicName}/produce-record`);
+
+ const largeContent = 'Large content '.repeat(1000); // ~14KB
+ const monacoEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
+ await monacoEditor.click();
+
+ await page.evaluate(`navigator.clipboard.writeText("${largeContent}")`);
+ await page.keyboard.press('Control+KeyV');
+ await page.keyboard.press('Meta+KeyV');
+
+ await page.getByTestId('produce-button').click();
+ await page.waitForTimeout(2000);
+ });
+
+ await test.step('Export large message', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // May need to click "Load anyway" for large message
+ const loadButton = page.getByTestId('load-anyway-button');
+ if (await loadButton.isVisible()) {
+ await loadButton.click();
+ }
+
+ // Expand message
+ await page.getByLabel('Collapse row').first().click();
+
+ // Export as JSON
+ await page.getByText('Download Record').click();
+
+ const downloadPromise = page.waitForEvent('download');
+ await page
+ .getByRole('dialog', { name: /save message/i })
+ .getByRole('button', { name: /save/i })
+ .click();
+
+ const download = await downloadPromise;
+ const tempFilePath = `/tmp/test-large-${Date.now()}.json`;
+ await download.saveAs(tempFilePath);
+
+ // Verify file size
+ const stats = fs.statSync(tempFilePath);
+ expect(stats.size).toBeGreaterThan(1000); // Should be larger than 1KB
+
+ // Cleanup
+ fs.unlinkSync(tempFilePath);
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should view message metadata', async ({ page }) => {
+ const topicName = `metadata-${Date.now()}`;
+ const messageValue = 'Message for metadata viewing';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, messageValue);
+
+ await test.step('View expanded message metadata', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Expand message
+ await page.getByLabel('Collapse row').first().click();
+
+ // Verify metadata is visible
+ await expect(page.getByText(/Offset|offset/i).first()).toBeVisible();
+ await expect(page.getByText(/Partition|partition/i).first()).toBeVisible();
+ await expect(page.getByText(/Timestamp|timestamp/i).first()).toBeVisible();
+
+ // Payload content should be visible
+ await expect(page.getByTestId('payload-content')).toBeVisible();
+ // Message value is already visible within payload-content
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+});
diff --git a/frontend/tests/console/topics/topic-messages-filtering.spec.ts b/frontend/tests/console/topics/topic-messages-filtering.spec.ts
new file mode 100644
index 0000000000..ad1578bcea
--- /dev/null
+++ b/frontend/tests/console/topics/topic-messages-filtering.spec.ts
@@ -0,0 +1,252 @@
+// spec: specs/topics.md
+/** biome-ignore-all lint/performance/useTopLevelRegex: these regexp are called as part of e2e tests and don't slow things down */
+// seed: tests/seed.spec.ts
+
+import { expect, test } from '@playwright/test';
+
+import { TopicPage } from '../utils/TopicPage';
+
+test.describe('View and Filter Messages', () => {
+ test('should expand message to view details', async ({ page }) => {
+ const topicName = `expand-message-${Date.now()}`;
+ const messageContent = 'Detailed message content';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, messageContent);
+
+ await test.step('Expand message row', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Wait for message to appear
+ await expect(page.getByText(messageContent)).toBeVisible();
+
+ // Click expand button
+ const expandButton = page.getByLabel('Collapse row').first();
+ await expandButton.click();
+
+ // Expanded details should be visible
+ await expect(page.getByTestId('payload-content')).toBeVisible({ timeout: 5000 });
+
+ // Metadata should be visible
+ await expect(page.getByText(/Offset|Partition|Timestamp/i).first()).toBeVisible();
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should search message content', async ({ page }) => {
+ const topicName = `search-messages-${Date.now()}`;
+ const searchTerm = 'searchable-keyword';
+ const message1 = `Message with ${searchTerm}`;
+ const message2 = 'Message without keyword';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, message1);
+ await topicPage.produceMessage(topicName, message2);
+
+ await test.step('Search for messages', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Find search input
+ const searchInput = page.getByPlaceholder(/search|filter/i);
+ if (await searchInput.isVisible()) {
+ await searchInput.fill(searchTerm);
+ await page.keyboard.press('Enter');
+
+ // Wait for filtering
+ await page.waitForTimeout(1000);
+
+ // Message with search term should be visible
+ await expect(page.getByText(message1)).toBeVisible();
+
+ // Message without search term may be hidden
+ // (behavior depends on implementation)
+ }
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ // biome-ignore lint/suspicious/noSkippedTests: Skip until we can control selects in a better way
+ test.skip('should filter messages by partition', async ({ page }) => {
+ const topicName = `filter-partition-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, 'Message for partition filter');
+
+ await test.step('Apply partition filter', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Look for partition filter dropdown
+ const partitionFilter = page.locator('text=Partition').first();
+ if (await partitionFilter.isVisible()) {
+ await partitionFilter.click();
+
+ // Select partition 0 (or first available)
+ const partition0 = page.locator('text=0').first();
+ if (await partition0.isVisible()) {
+ await partition0.click();
+
+ // Messages should filter to selected partition
+ await page.waitForTimeout(1000);
+ }
+ }
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should filter messages by offset', async ({ page }) => {
+ const topicName = `filter-offset-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ // Produce multiple messages
+ for (let i = 0; i < 3; i++) {
+ await topicPage.produceMessage(topicName, `Message ${i}`);
+ }
+
+ await test.step('Filter by start offset', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Look for offset filter input
+ const offsetInput = page.getByPlaceholder(/offset/i);
+ if (await offsetInput.isVisible()) {
+ // Set start offset to 1 (skip first message)
+ await offsetInput.fill('1');
+ await page.keyboard.press('Enter');
+
+ // Wait for filtering
+ await page.waitForTimeout(1000);
+
+ // Messages from offset 1 onwards should be visible
+ await expect(page.getByTestId('data-table-cell').first()).toBeVisible();
+ }
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should clear all filters', async ({ page }) => {
+ const topicName = `clear-filters-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, 'Message for filter clearing');
+
+ await test.step('Apply and clear filters', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Apply a search filter
+ const searchInput = page.getByPlaceholder(/search|filter/i);
+ if (await searchInput.isVisible()) {
+ await searchInput.fill('some-filter');
+ await page.keyboard.press('Enter');
+ await page.waitForTimeout(500);
+ }
+
+ // Look for clear filters button
+ const clearButton = page.getByRole('button', { name: /clear|reset/i });
+ if (await clearButton.isVisible()) {
+ await clearButton.click();
+
+ // All messages should be visible again
+ await expect(page.getByText('Message for filter clearing')).toBeVisible();
+ }
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should handle empty topic with no messages', async ({ page }) => {
+ const topicName = `empty-topic-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('View empty topic', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Should show empty state message
+ await expect(page.getByText(/No messages|empty/i).first()).toBeVisible({ timeout: 5000 });
+
+ // Produce button should still be available
+ await expect(page.getByRole('button', { name: /produce/i }).first()).toBeVisible({ timeout: 5000 });
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should handle rapid filter changes gracefully', async ({ page }) => {
+ const topicName = `rapid-filter-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, 'Test rapid filtering');
+
+ await test.step('Rapidly change filters', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ const searchInput = page.getByPlaceholder(/search|filter/i);
+ if (await searchInput.isVisible()) {
+ // Rapidly change search terms
+ await searchInput.fill('filter1');
+ await searchInput.fill('filter2');
+ await searchInput.fill('filter3');
+ await searchInput.clear();
+ await searchInput.fill('Test');
+
+ // Wait for final filter to apply
+ await page.waitForTimeout(1000);
+
+ // Should handle gracefully without errors
+ await expect(page.getByText('Test rapid filtering')).toBeVisible();
+ }
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should preserve filters in URL parameters', async ({ page }) => {
+ const topicName = `url-filters-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, 'URL filter test');
+
+ await test.step('Apply filter and check URL', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // Use the specific testId for message quick search
+ const searchInput = page.getByTestId('message-quick-search-input');
+ await expect(searchInput).toBeVisible({ timeout: 5000 });
+ await searchInput.fill('test-search');
+ await page.keyboard.press('Enter');
+ await page.waitForTimeout(500);
+
+ // URL should contain filter parameters (quick search uses 'q' param)
+ const currentUrl = page.url();
+ expect(currentUrl).toContain('q=test-search');
+ });
+
+ await test.step('Reload page and verify filter persists', async () => {
+ // Reload the page
+ await page.reload();
+
+ // Wait for page to load
+ await expect(page.getByTestId('message-quick-search-input')).toBeVisible({ timeout: 5000 });
+
+ // Verify the search parameter is still in URL
+ expect(page.url()).toContain('q=test-search');
+
+ // Verify the search input has the value
+ await expect(page.getByTestId('message-quick-search-input')).toHaveValue('test-search');
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+});
diff --git a/frontend/tests/console/topics/topic-messages-production.spec.ts b/frontend/tests/console/topics/topic-messages-production.spec.ts
new file mode 100644
index 0000000000..957f3b565b
--- /dev/null
+++ b/frontend/tests/console/topics/topic-messages-production.spec.ts
@@ -0,0 +1,254 @@
+// spec: specs/topics.md
+// seed: tests/seed.spec.ts
+
+import { expect, test } from '@playwright/test';
+
+import { TopicPage } from '../utils/TopicPage';
+
+test.use({
+ permissions: ['clipboard-write', 'clipboard-read'],
+});
+
+test.describe('Produce Messages', () => {
+ test('should produce simple text message', async ({ page }) => {
+ const topicName = `text-message-${Date.now()}`;
+ const messageContent = 'Hello Redpanda Console';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await topicPage.produceMessage(topicName, messageContent);
+
+ await test.step('Verify message in messages tab', async () => {
+ await page.goto(`/topics/${topicName}`);
+ await expect(page.getByText(messageContent)).toBeVisible();
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test.skip('should produce message with key', async ({ page }) => {
+ const topicName = `keyed-message-${Date.now()}`;
+ const messageKey = 'user-123';
+ const messageValue = 'User data for user 123';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('Produce message with key', async () => {
+ await page.goto(`/topics/${topicName}/produce-record`);
+
+ // Wait for page to load
+ await expect(page.getByTestId('produce-button')).toBeVisible();
+
+ // Find and fill key editor
+ const keyEditor = page.getByTestId('produce-key-editor').locator('.monaco-editor').first();
+ await keyEditor.click();
+ await page.keyboard.insertText(messageKey);
+
+ // Find and fill value editor
+ const valueEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
+ await valueEditor.click();
+ await page.keyboard.insertText(messageValue);
+
+ // Produce message
+ await page.getByTestId('produce-button').click();
+
+ // Verify message was produced - page should show the message
+ await expect(page.getByRole('cell', { name: new RegExp(messageValue, 'i') }).first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should produce multiple messages in sequence', async ({ page }) => {
+ const topicName = `multi-produce-${Date.now()}`;
+ const messages = ['Message One', 'Message Two', 'Message Three'];
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ for (const message of messages) {
+ await test.step(`Produce message: ${message}`, async () => {
+ await page.goto(`/topics/${topicName}/produce-record`);
+
+ const valueEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
+ await valueEditor.click();
+
+ // Clear any existing content before inserting new text
+ await page.keyboard.press('Meta+A'); // Select all (Mac)
+ await page.keyboard.press('Control+A'); // Select all (Windows/Linux)
+ await page.keyboard.press('Backspace');
+
+ await page.keyboard.insertText(message);
+ await page.getByTestId('produce-button').click();
+
+ // Verify message appears
+ await expect(page.getByRole('cell', { name: new RegExp(message, 'i') }).first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+ }
+
+ await test.step('Verify all messages in topic', async () => {
+ await page.goto(`/topics/${topicName}`);
+
+ // All messages should be visible
+ for (const message of messages) {
+ await expect(page.getByText(message)).toBeVisible();
+ }
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should produce large message and handle display limit', async ({ page }) => {
+ const topicName = `large-message-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await page.goto(`/topics/${topicName}/produce-record`);
+
+ await test.step('Produce large message', async () => {
+ // Create message larger than 20KB (DefaultMaxDeserializationPayloadSize)
+ const maxMessageSize = 30_000;
+ const fillText = 'example content ';
+ const content = fillText.repeat(Math.floor(maxMessageSize / fillText.length) + 1);
+
+ const monacoEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
+ await monacoEditor.click();
+
+ // Copy to clipboard and paste (faster than typing)
+ await page.evaluate(`navigator.clipboard.writeText("${content}")`);
+ await page.keyboard.press('Control+KeyV');
+ await page.keyboard.press('Meta+KeyV');
+
+ await page.getByTestId('produce-button').click();
+
+ // Wait for message to appear with size warning
+ await page.getByText('Message size exceeds the display limit.').waitFor({
+ state: 'visible',
+ timeout: 10_000,
+ });
+ });
+
+ await test.step('Load large message', async () => {
+ // Click on the message row to expand
+ await page.getByTestId('data-table-cell').first().getByRole('button').click();
+
+ // Should show warning about performance
+ await page
+ .getByText(
+ 'Because this message size exceeds the display limit, loading it could cause performance degradation.'
+ )
+ .waitFor({
+ state: 'visible',
+ timeout: 5000,
+ });
+
+ // Click "Load anyway"
+ await page.getByTestId('load-anyway-button').click();
+
+ // Full message content should load
+ await expect(page.getByTestId('payload-content')).toBeVisible({ timeout: 5000 });
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should navigate to produce page and see form elements', async ({ page }) => {
+ const topicName = `produce-ui-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('Navigate to produce page', async () => {
+ await page.goto(`/topics/${topicName}/produce-record`);
+
+ // Verify produce page loaded with all elements
+ await expect(page.getByTestId('produce-button')).toBeVisible();
+ await expect(page.getByTestId('produce-value-editor')).toBeVisible();
+ await expect(page.getByTestId('produce-key-editor')).toBeVisible();
+
+ // Page title or heading should indicate produce/publish
+ await expect(page.locator('text=/Produce|Publish/i').first()).toBeVisible({ timeout: 5000 });
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should handle empty message production', async ({ page }) => {
+ const topicName = `empty-message-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('Try to produce empty message', async () => {
+ await page.goto(`/topics/${topicName}/produce-record`);
+
+ // Don't enter any value
+ const valueEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
+ await valueEditor.click();
+
+ // Try to produce
+ await page.getByTestId('produce-button').click();
+
+ // Message might be produced (empty is valid) or button might be disabled
+ // Just verify no crash occurs
+ await page.waitForTimeout(2000);
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should clear editor content between produces', async ({ page }) => {
+ const topicName = `clear-editor-${Date.now()}`;
+ const message1 = 'First message';
+ const message2 = 'Second message';
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('Produce first message', async () => {
+ await page.goto(`/topics/${topicName}/produce-record`);
+ const valueEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
+ await valueEditor.click();
+ await page.keyboard.insertText(message1);
+ await page.getByTestId('produce-button').click();
+
+ await expect(page.getByRole('cell', { name: new RegExp(message1, 'i') }).first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ await test.step('Produce second message - editor should be clear or have previous content', async () => {
+ await page.goto(`/topics/${topicName}/produce-record`);
+
+ // Editor might be cleared or might have previous content
+ const valueEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
+ await valueEditor.click();
+
+ // Select all and delete to ensure clean state
+ await page.keyboard.press('Control+A');
+ await page.keyboard.press('Meta+A');
+ await page.keyboard.press('Backspace');
+
+ // Type new message
+ await page.keyboard.insertText(message2);
+ await page.getByTestId('produce-button').click();
+
+ await expect(page.getByRole('cell', { name: new RegExp(message2, 'i') }).first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ await test.step('Verify both messages exist', async () => {
+ await page.goto(`/topics/${topicName}`);
+ await expect(page.getByText(message1)).toBeVisible();
+ await expect(page.getByText(message2)).toBeVisible();
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+});
diff --git a/frontend/tests/console/topics/topic-navigation.spec.ts b/frontend/tests/console/topics/topic-navigation.spec.ts
new file mode 100644
index 0000000000..ddc18c7388
--- /dev/null
+++ b/frontend/tests/console/topics/topic-navigation.spec.ts
@@ -0,0 +1,135 @@
+// spec: specs/topics.md
+// seed: tests/seed.spec.ts
+
+import { expect, test } from '@playwright/test';
+
+import { TopicPage } from '../utils/TopicPage';
+
+test.describe('Topic Details - Navigation and Tabs', () => {
+ test('should navigate to topic details and view basic information', async ({ page }) => {
+ const topicName = `nav-test-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('Navigate to topic details', async () => {
+ await page.goto('/topics');
+ await page.getByTestId(`topic-link-${topicName}`).click();
+
+ // URL changes to topic details
+ await expect(page).toHaveURL(new RegExp(`/topics/${topicName}`));
+
+ // Topic name appears
+ await expect(page.getByText(topicName)).toBeVisible();
+
+ // Topic tabs should be visible
+ await expect(page.getByRole('tablist')).toBeVisible({ timeout: 10_000 });
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should view messages tab as default', async ({ page }) => {
+ const topicName = `messages-tab-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('Navigate to topic and verify Messages tab', async () => {
+ await page.getByTestId(`topic-link-${topicName}`).click();
+
+ // Topic tabs should be visible
+ await expect(page.getByRole('tablist')).toBeVisible();
+
+ // Messages tab should be active by default
+ await expect(page.locator('text=Messages').first()).toBeVisible();
+
+ // Message-related elements should be visible
+ await expect(page.getByText(/No messages found|Partition|Offset/i).first()).toBeVisible({ timeout: 10_000 });
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should navigate to tab via URL hash', async ({ page }) => {
+ const topicName = `url-hash-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('Navigate directly to Configuration tab via URL', async () => {
+ await page.goto(`/topics/${topicName}#configuration`);
+
+ // Configuration tab should be active
+ await expect(page.getByTestId('config-group-table')).toBeVisible({ timeout: 5000 });
+ });
+
+ await test.step('Navigate directly to Partitions tab via URL', async () => {
+ await page.goto(`/topics/${topicName}#partitions`);
+
+ // Partitions tab content should be visible
+ await expect(page.getByText(/Partition|Leader/i).first()).toBeVisible({ timeout: 5000 });
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should view configuration tab with grouped settings', async ({ page }) => {
+ const topicName = `config-groups-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+
+ await test.step('Navigate to Configuration tab', async () => {
+ await page.goto(`/topics/${topicName}#configuration`);
+ await expect(page.getByTestId('config-group-table')).toBeVisible();
+
+ // Verify configuration groups are present (in expected order)
+ const expectedGroups = [
+ 'Retention',
+ 'Compaction',
+ 'Replication',
+ 'Tiered Storage',
+ 'Write Caching',
+ 'Iceberg',
+ 'Schema Registry and Validation',
+ 'Message Handling',
+ 'Compression',
+ 'Storage Internals',
+ ];
+
+ // At least Retention group should be visible
+ await expect(page.locator('.configGroupTitle').filter({ hasText: 'Retention' })).toBeVisible();
+
+ // Get all visible groups
+ const visibleGroups = await page.locator('.configGroupTitle').allTextContents();
+ const filteredGroups = visibleGroups.filter((group) => expectedGroups.includes(group));
+
+ // Verify at least some groups are present and in order
+ expect(filteredGroups.length).toBeGreaterThan(0);
+ expect(filteredGroups).toContain('Retention');
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+
+ test('should navigate back to topics list via breadcrumb', async ({ page }) => {
+ const topicName = `breadcrumb-${Date.now()}`;
+
+ const topicPage = new TopicPage(page);
+ await topicPage.createTopic(topicName);
+ await page.goto('/topics');
+ await page.getByTestId(`topic-link-${topicName}`).click();
+
+ await test.step('Navigate back using breadcrumb', async () => {
+ // Click on "Topics" breadcrumb
+ await page.getByRole('link', { name: 'Topics' }).first().click();
+
+ // Should return to topics list
+ await expect(page).toHaveURL(/\/topics/);
+ await expect(page.getByTestId(`topic-link-${topicName}`)).toBeVisible();
+ });
+
+ await topicPage.deleteTopic(topicName);
+ });
+});
diff --git a/frontend/tests/console/transforms.spec.ts b/frontend/tests/console/transforms/transforms.spec.ts
similarity index 100%
rename from frontend/tests/console/transforms.spec.ts
rename to frontend/tests/console/transforms/transforms.spec.ts
diff --git a/frontend/tests/console/pages/ACLPage.ts b/frontend/tests/console/utils/ACLPage.ts
similarity index 99%
rename from frontend/tests/console/pages/ACLPage.ts
rename to frontend/tests/console/utils/ACLPage.ts
index d12ae8113a..25027b842c 100644
--- a/frontend/tests/console/pages/ACLPage.ts
+++ b/frontend/tests/console/utils/ACLPage.ts
@@ -373,10 +373,10 @@ export class ACLPage {
_principal: string,
operationName: string,
permission: OperationType,
- _host = '*',
+ _host = '*'
) {
const detailOperationItem = this.page.getByTestId(
- `detail-item-op-${getIdFromRule(rule, operationName, permission)}`,
+ `detail-item-op-${getIdFromRule(rule, operationName, permission)}`
);
await expect(detailOperationItem).toBeVisible();
diff --git a/frontend/tests/console/utils/DebugBundlePage.ts b/frontend/tests/console/utils/DebugBundlePage.ts
new file mode 100644
index 0000000000..cb3db1cca5
--- /dev/null
+++ b/frontend/tests/console/utils/DebugBundlePage.ts
@@ -0,0 +1,357 @@
+/** biome-ignore-all lint/performance/useTopLevelRegex: this is a test */
+import type { Download, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+
+/**
+ * Page Object Model for Debug Bundle pages
+ * Handles creation, progress monitoring, and bundle management
+ */
+export class DebugBundlePage {
+ readonly page: Page;
+
+ constructor(page: Page) {
+ this.page = page;
+ }
+
+ /**
+ * Navigation methods
+ */
+ async goto() {
+ await this.page.goto('/debug-bundle');
+ await expect(this.page.getByRole('heading', { name: /debug bundle/i })).toBeVisible();
+ }
+
+ async gotoProgress(bundleId?: string) {
+ if (bundleId) {
+ await this.page.goto(`/debug-bundle/progress/${bundleId}`);
+ } else {
+ await this.page.goto('/debug-bundle');
+ const progressLink = this.page.getByRole('link', { name: /progress|view progress|in progress/i });
+ await expect(progressLink).toBeVisible();
+ await progressLink.click();
+ }
+ await expect(this.page).toHaveURL(/\/debug-bundle\/progress\//);
+ }
+
+ async waitForProgressPage() {
+ await this.page.waitForURL(/\/debug-bundle\/progress\//);
+ }
+
+ /**
+ * Mode switching
+ */
+ async switchToAdvancedMode() {
+ const advancedButton = this.page.getByTestId('switch-to-custom-debug-bundle-form');
+ await advancedButton.click();
+
+ // Verify advanced options are visible
+ await expect(
+ this.page.getByText(
+ /This is an advanced feature, best used if you have received direction to do so from Redpanda support./i
+ )
+ ).toBeVisible();
+ }
+
+ async switchToBasicMode() {
+ const basicButton = this.page.getByTestId('switch-to-basic-debug-bundle-form');
+ if (await basicButton.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await basicButton.click();
+ }
+ }
+
+ /**
+ * Form input methods
+ */
+ async setCpuProfilerSeconds(seconds: number) {
+ const cpuInput = this.page.getByLabel(/cpu profiler/i).or(this.page.locator('input[data-testid*="cpu-profiler"]'));
+ await cpuInput.fill(String(seconds));
+ }
+
+ async setControllerLogSizeMB(sizeMB: number) {
+ const logSizeInput = this.page
+ .getByLabel(/controller log.*size/i)
+ .or(this.page.locator('input[data-testid*="controller-log-size"]'));
+ await logSizeInput.fill(String(sizeMB));
+ }
+
+ async setLogsSizeLimitMB(sizeMB: number) {
+ const logsLimitInput = this.page
+ .getByLabel(/logs.*size.*limit/i)
+ .or(this.page.locator('input[data-testid*="logs-size-limit"]'));
+ await logsLimitInput.fill(String(sizeMB));
+ }
+
+ async setMetricsIntervalSeconds(seconds: number) {
+ const metricsInput = this.page
+ .getByLabel(/metrics.*interval/i)
+ .or(this.page.locator('input[data-testid*="metrics-interval"]'));
+ await metricsInput.fill(String(seconds));
+ }
+
+ async setEnableTLS(enabled: boolean) {
+ const tlsCheckbox = this.page.getByRole('checkbox', { name: /enable tls|tls/i });
+ if (enabled) {
+ await tlsCheckbox.check();
+ } else {
+ await tlsCheckbox.uncheck();
+ }
+ }
+
+ /**
+ * Advanced form configuration
+ */
+ async fillAdvancedForm(options: {
+ cpuProfilerSeconds?: number;
+ controllerLogSizeMB?: number;
+ logsSizeLimitMB?: number;
+ metricsIntervalSeconds?: number;
+ enableTLS?: boolean;
+ }) {
+ await this.switchToAdvancedMode();
+
+ if (options.cpuProfilerSeconds !== undefined) {
+ await this.setCpuProfilerSeconds(options.cpuProfilerSeconds);
+ }
+
+ if (options.controllerLogSizeMB !== undefined) {
+ await this.setControllerLogSizeMB(options.controllerLogSizeMB);
+ }
+
+ if (options.logsSizeLimitMB !== undefined) {
+ await this.setLogsSizeLimitMB(options.logsSizeLimitMB);
+ }
+
+ if (options.metricsIntervalSeconds !== undefined) {
+ await this.setMetricsIntervalSeconds(options.metricsIntervalSeconds);
+ }
+
+ if (options.enableTLS !== undefined) {
+ await this.setEnableTLS(options.enableTLS);
+ }
+ }
+
+ /**
+ * Bundle generation actions
+ */
+ async generate(options?: { confirmOverwrite?: boolean }) {
+ const generateButton = this.page.getByRole('button', { name: /generate/i }).first();
+ await expect(generateButton).toBeVisible();
+ await generateButton.click();
+
+ // Check if confirmation dialog appears
+ const confirmDialog = this.page.getByRole('dialog').or(this.page.getByText(/are you sure|confirm|replace/i));
+ const hasConfirm = await confirmDialog.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (hasConfirm) {
+ if (options?.confirmOverwrite) {
+ // Confirm to proceed with generation
+ const confirmButton = this.page.getByRole('button', { name: /confirm|yes|replace/i });
+ await confirmButton.click();
+ } else {
+ // Cancel to avoid generating
+ const cancelButton = this.page.getByRole('button', { name: /cancel|no/i });
+ await cancelButton.click();
+ return; // Exit early since we cancelled
+ }
+ }
+
+ // Wait for navigation to progress page
+ await this.waitForProgressPage();
+ }
+
+ async generateBasicBundle() {
+ await this.goto();
+ await this.generate({ confirmOverwrite: true });
+ await expect(this.page.getByText(/generating/i)).toBeVisible({ timeout: 10_000 });
+ }
+
+ async generateAdvancedBundle(options: {
+ cpuProfilerSeconds?: number;
+ controllerLogSizeMB?: number;
+ logsSizeLimitMB?: number;
+ metricsIntervalSeconds?: number;
+ enableTLS?: boolean;
+ }) {
+ await this.goto();
+ await this.fillAdvancedForm(options);
+
+ const generateButton = this.page.getByRole('button', { name: /generate/i }).first();
+ await expect(generateButton).toBeVisible();
+ await generateButton.click();
+
+ await this.waitForProgressPage();
+ }
+
+ async cancelGeneration() {
+ const stopButton = this.page.getByTestId('debug-bundle-stop-button');
+ await expect(stopButton).toBeVisible();
+ await stopButton.click();
+
+ // Confirm if there's a confirmation dialog
+ const confirmButton = this.page.getByRole('button', { name: /confirm|yes|stop/i });
+ if (await confirmButton.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await confirmButton.click();
+ }
+
+ await this.page.waitForTimeout(1000);
+ }
+
+ async clickDoneButton() {
+ const doneButton = this.page.getByTestId('debug-bundle-done-button');
+ await expect(doneButton).toBeVisible();
+ await expect(doneButton).toHaveText('Done');
+ await doneButton.click();
+ }
+
+ async clickTryAgainButton() {
+ const tryAgainButton = this.page.getByTestId('debug-bundle-try-again-button');
+ await expect(tryAgainButton).toBeVisible();
+ await expect(tryAgainButton).toHaveText('Try again');
+ await tryAgainButton.click();
+ }
+
+ /**
+ * Bundle download and deletion
+ */
+ async downloadBundle(): Promise {
+ const downloadLink = this.page.getByRole('link', { name: /download/i });
+ await expect(downloadLink).toBeVisible({ timeout: 5000 });
+
+ const downloadPromise = this.page.waitForEvent('download');
+ await downloadLink.click();
+
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toMatch(/debug-bundle\.zip/);
+
+ return download;
+ }
+
+ async deleteBundle() {
+ const deleteButton = this.page
+ .getByRole('button', { name: /delete/i })
+ .or(this.page.locator('button[aria-label*="delete"]'));
+
+ await expect(deleteButton).toBeVisible();
+ await deleteButton.click();
+
+ // Confirm deletion if modal appears
+ const confirmButton = this.page.getByRole('button', { name: /confirm|yes|delete/i });
+ if (await confirmButton.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await confirmButton.click();
+ }
+
+ await expect(this.page.getByText(/deleted|removed/i)).toBeVisible({ timeout: 5000 });
+ }
+
+ /**
+ * Status checking methods
+ */
+ async isBundleGenerationInProgress(): Promise {
+ await this.goto();
+
+ const progressLink = this.page.getByRole('link', { name: /progress|in progress/i });
+ return progressLink.isVisible({ timeout: 2000 }).catch(() => false);
+ }
+
+ async waitForBundleCompletion(timeout = 60_000) {
+ await Promise.race([
+ this.page.getByText(/complete|success|ready/i).waitFor({ timeout }),
+ this.page.getByText(/download/i).waitFor({ timeout }),
+ this.page.getByText(/error|failed/i).waitFor({ timeout }),
+ ]);
+ }
+
+ /**
+ * Validation methods
+ */
+ async verifyBrokerStatus() {
+ await expect(this.page).toHaveURL(/\/debug-bundle\/progress\//);
+
+ // Verify the overview component is visible
+ const overview = this.page.getByTestId('debug-bundle-overview');
+ await expect(overview).toBeVisible({ timeout: 5000 });
+
+ // Verify at least one broker status is visible
+ const brokerStatus = this.page.locator('[data-testid^="debug-bundle-broker-status-"]').first();
+ await expect(brokerStatus).toBeVisible({ timeout: 5000 });
+ }
+
+ async verifyGenerationInProgress() {
+ const generatingText = this.page.getByTestId('debug-bundle-generating-text');
+ await expect(generatingText).toBeVisible();
+ await expect(generatingText).toHaveText('Generating bundle...');
+ }
+
+ async verifyGenerationComplete() {
+ const completeBox = this.page.getByTestId('debug-bundle-complete-box');
+ await expect(completeBox).toBeVisible({ timeout: 60_000 });
+ }
+
+ async verifyGenerationFailed() {
+ const errorText = this.page.getByTestId('debug-bundle-error-text');
+ await expect(errorText).toBeVisible();
+ await expect(errorText).toHaveText('Your debug bundle was not generated.');
+ }
+
+ async verifyBundleExpired() {
+ const expiredText = this.page.getByTestId('debug-bundle-expired-text');
+ await expect(expiredText).toBeVisible();
+ await expect(expiredText).toHaveText('Your previous bundle has expired and cannot be downloaded.');
+ }
+
+ async verifyAdvancedModeActive() {
+ await expect(
+ this.page.getByText(
+ /This is an advanced feature, best used if you have received direction to do so from Redpanda support./i
+ )
+ ).toBeVisible();
+ }
+
+ async verifyBasicModeActive() {
+ const generateButton = this.page.getByRole('button', { name: /generate/i }).first();
+ await expect(generateButton).toBeVisible();
+ }
+
+ /**
+ * Verify specific broker status
+ */
+ async verifyBrokerStatusById(brokerId: number) {
+ const brokerStatus = this.page.getByTestId(`debug-bundle-broker-status-${brokerId}`);
+ await expect(brokerStatus).toBeVisible();
+ }
+
+ async verifyBrokerLabel(brokerId: number) {
+ const brokerLabel = this.page.getByTestId(`broker-${brokerId}-label`);
+ await expect(brokerLabel).toBeVisible();
+ await expect(brokerLabel).toHaveText(`Broker ${brokerId}`);
+ }
+
+ async verifyBrokerError(brokerId: number, expectedMessage?: string) {
+ const errorLabel = this.page.getByTestId(`broker-${brokerId}-error-label`);
+ await expect(errorLabel).toBeVisible();
+
+ if (expectedMessage) {
+ const errorMessage = this.page.getByTestId(`broker-${brokerId}-error-message`);
+ await expect(errorMessage).toHaveText(expectedMessage);
+ }
+ }
+
+ async verifyDefaultModeDescription() {
+ const description = this.page.getByTestId('debug-bundle-description-default-mode');
+ await expect(description).toBeVisible();
+ await expect(description).toContainText('Collect environment data');
+ }
+
+ async verifyAdvancedModeDescription() {
+ const description = this.page.getByTestId('debug-bundle-description-advanced-mode');
+ await expect(description).toBeVisible();
+ await expect(description).toContainText('Collect environment data');
+ }
+
+ /**
+ * Helper methods
+ */
+ async waitForStability(timeout = 1000) {
+ await this.page.waitForTimeout(timeout);
+ }
+}
diff --git a/frontend/tests/console/pages/RolePage.ts b/frontend/tests/console/utils/RolePage.ts
similarity index 73%
rename from frontend/tests/console/pages/RolePage.ts
rename to frontend/tests/console/utils/RolePage.ts
index ac214d8c6d..e3085c3d1d 100644
--- a/frontend/tests/console/pages/RolePage.ts
+++ b/frontend/tests/console/utils/RolePage.ts
@@ -1,5 +1,5 @@
/** biome-ignore-all lint/performance/useTopLevelRegex: this is a test */
-import { expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
import { ACLPage } from './ACLPage';
@@ -128,4 +128,51 @@ export class RolePage extends ACLPage {
const memberElement = this.page.locator('.text-left').filter({ hasText: username });
await memberElement.waitFor({ state: 'hidden', timeout: 5000 });
}
+
+ /**
+ * High-level convenience methods
+ * These combine multiple operations for common workflows
+ */
+
+ /**
+ * Creates a role with the given name through the UI
+ * Uses allow-all-operations for simplicity
+ */
+ async createRole(roleName: string) {
+ return await test.step('Create role', async () => {
+ await this.gotoList();
+ await this.page.getByTestId('create-role-button').click({
+ force: true,
+ });
+
+ await this.page.waitForURL('/security/roles/create', {
+ waitUntil: 'domcontentloaded',
+ });
+ await this.page.getByLabel('Role name').fill(roleName);
+ await this.page.getByTestId('roles-allow-all-operations').click({
+ force: true,
+ });
+ await this.page.getByRole('button').getByText('Create').click({
+ force: true,
+ });
+ await this.page.waitForURL(`/security/roles/${roleName}/details`);
+ });
+ }
+
+ /**
+ * Deletes a role with the given name through the UI
+ */
+ async deleteRole(roleName: string) {
+ return await test.step('Delete role', async () => {
+ await this.gotoDetail(roleName);
+ await this.page.getByRole('button').getByText('Delete').click();
+ await this.page.getByPlaceholder(`Type "${roleName}" to confirm`).fill(roleName);
+ await this.page.getByTestId('test-delete-item').click({
+ force: true,
+ });
+ await this.page.waitForURL(`/security/roles/${roleName}/details`, {
+ waitUntil: 'domcontentloaded',
+ });
+ });
+ }
}
diff --git a/frontend/tests/console/utils/SecurityPage.ts b/frontend/tests/console/utils/SecurityPage.ts
new file mode 100644
index 0000000000..8c3e500cc0
--- /dev/null
+++ b/frontend/tests/console/utils/SecurityPage.ts
@@ -0,0 +1,88 @@
+/** biome-ignore-all lint/performance/useTopLevelRegex: this is a test */
+import { type Page, test } from '@playwright/test';
+
+/**
+ * Page Object Model for Security pages
+ * Handles user management and other security-related operations
+ */
+export class SecurityPage {
+ constructor(protected page: Page) {}
+
+ /**
+ * Navigation methods
+ */
+ async goToUsersList() {
+ await this.page.goto('/security/users');
+ }
+
+ async goToUserDetails(username: string) {
+ await this.page.goto(`/security/users/${username}/details`);
+ }
+
+ async goToCreateUser() {
+ await this.page.goto('/security/users/create');
+ }
+
+ /**
+ * User list operations
+ */
+ async clickCreateUserButton() {
+ await this.page.getByTestId('create-user-button').click();
+ }
+
+ /**
+ * User creation operations
+ */
+ async fillUsername(username: string) {
+ await this.page.getByLabel('Username').fill(username);
+ }
+
+ async submitUserCreation() {
+ await this.page.getByRole('button').getByText('Create').click({
+ force: true,
+ });
+ }
+
+ /**
+ * User deletion operations
+ */
+ async clickDeleteButton() {
+ await this.page.getByRole('button').getByText('Delete').click();
+ }
+
+ async confirmUserDeletion(username: string) {
+ await this.page.getByPlaceholder(`Type "${username}" to confirm`).fill(username);
+ await this.page.getByTestId('test-delete-item').click({
+ force: true,
+ });
+ }
+
+ /**
+ * High-level convenience methods
+ * These combine multiple operations for common workflows
+ */
+
+ /**
+ * Creates a user with the given username through the UI
+ */
+ async createUser(username: string) {
+ return await test.step('Create user', async () => {
+ await this.goToUsersList();
+ await this.clickCreateUserButton();
+ await this.page.waitForURL('/security/users/create');
+ await this.fillUsername(username);
+ await this.submitUserCreation();
+ });
+ }
+
+ /**
+ * Deletes a user with the given username through the UI
+ */
+ async deleteUser(username: string) {
+ return await test.step('Delete user', async () => {
+ await this.goToUserDetails(username);
+ await this.clickDeleteButton();
+ await this.confirmUserDeletion(username);
+ });
+ }
+}
diff --git a/frontend/tests/console/utils/TopicPage.ts b/frontend/tests/console/utils/TopicPage.ts
new file mode 100644
index 0000000000..8b23b34b82
--- /dev/null
+++ b/frontend/tests/console/utils/TopicPage.ts
@@ -0,0 +1,287 @@
+/** biome-ignore-all lint/performance/useTopLevelRegex: this is a test */
+import { expect, type Page, test } from '@playwright/test';
+
+/**
+ * Page Object Model for Topic pages
+ * Encapsulates common topic-related operations for E2E tests
+ */
+export class TopicPage {
+ constructor(protected page: Page) {}
+
+ /**
+ * Navigation methods
+ */
+ async goToTopicsList() {
+ await this.page.goto('/topics');
+ await expect(this.page.getByTestId('create-topic-button')).toBeVisible();
+ }
+
+ async goToTopicDetails(topicName: string) {
+ await this.page.goto(`/topics/${topicName}`);
+ await expect(this.page.getByText(topicName)).toBeVisible();
+ }
+
+ async goToTopicTab(topicName: string, tab: 'messages' | 'configuration' | 'partitions' | 'consumers') {
+ await this.page.goto(`/topics/${topicName}#${tab}`);
+ }
+
+ async goToProduceRecord(topicName: string) {
+ await this.page.goto(`/topics/${topicName}/produce-record`);
+ await expect(this.page.getByTestId('produce-button')).toBeVisible();
+ }
+
+ /**
+ * Topic list operations
+ */
+ async clickCreateTopicButton() {
+ await this.page.getByTestId('create-topic-button').click();
+ await expect(this.page.getByTestId('topic-name')).toBeVisible({ timeout: 5000 });
+ }
+
+ async searchTopics(searchTerm: string) {
+ const searchInput = this.page.getByTestId('search-field-input');
+ await searchInput.fill(searchTerm);
+ }
+
+ async clearSearch() {
+ const searchInput = this.page.getByTestId('search-field-input');
+ await searchInput.clear();
+ }
+
+ async toggleInternalTopics(checked: boolean) {
+ const checkbox = this.page.getByTestId('show-internal-topics-checkbox');
+ if (checked) {
+ await checkbox.check();
+ } else {
+ await checkbox.uncheck();
+ }
+ }
+
+ async clickTopicLink(topicName: string) {
+ await this.page.getByTestId(`topic-link-${topicName}`).click();
+ }
+
+ async verifyTopicInList(topicName: string) {
+ await expect(this.page.getByTestId(`topic-link-${topicName}`)).toBeVisible();
+ }
+
+ async verifyTopicNotInList(topicName: string) {
+ await expect(this.page.getByTestId(`topic-link-${topicName}`)).not.toBeVisible();
+ }
+
+ /**
+ * Topic creation operations
+ */
+ async fillTopicName(topicName: string) {
+ await this.page.getByTestId('topic-name').fill(topicName);
+ }
+
+ async fillPartitions(partitions: string) {
+ const partitionsInput = this.page.getByPlaceholder(/partitions/i);
+ if (await partitionsInput.isVisible()) {
+ await partitionsInput.fill(partitions);
+ }
+ }
+
+ async fillReplicationFactor(replicationFactor: string) {
+ const replicationInput = this.page.getByPlaceholder(/replication/i);
+ if ((await replicationInput.isVisible()) && !(await replicationInput.isDisabled())) {
+ await replicationInput.fill(replicationFactor);
+ }
+ }
+
+ async submitTopicCreation() {
+ await this.page.getByTestId('onOk-button').click();
+ }
+
+ async closeSuccessModal() {
+ await expect(this.page.getByTestId('create-topic-success__close-button')).toBeVisible();
+ await this.page.getByTestId('create-topic-success__close-button').click();
+ }
+
+ async cancelTopicCreation() {
+ await this.page.getByRole('button', { name: 'Cancel' }).click();
+ }
+
+ async verifyCreateButtonDisabled() {
+ await expect(this.page.getByTestId('onOk-button')).toBeDisabled();
+ }
+
+ async verifyCreateButtonEnabled() {
+ await expect(this.page.getByTestId('onOk-button')).toBeEnabled();
+ }
+
+ /**
+ * Topic details operations
+ */
+ async verifyTopicDetailsVisible(topicName: string) {
+ await expect(this.page).toHaveURL(new RegExp(`/topics/${topicName}`));
+ await expect(this.page.getByText(topicName)).toBeVisible();
+ }
+
+ async verifyTabsVisible() {
+ await expect(this.page.getByRole('tablist')).toBeVisible({ timeout: 10_000 });
+ }
+
+ async clickProduceRecordButton() {
+ await this.page.getByTestId('produce-record-button').click();
+ }
+
+ /**
+ * Message operations
+ */
+ async expandFirstMessage() {
+ await this.page.getByLabel('Collapse row').first().click();
+ }
+
+ async clickDownloadRecord() {
+ await this.page.getByText('Download Record').click();
+ }
+
+ async selectExportFormat(format: 'json' | 'csv') {
+ if (format === 'csv') {
+ await this.page.getByTestId('csv_field').click();
+ }
+ // JSON is default, no action needed
+ }
+
+ async confirmExport() {
+ const dialog = this.page.getByRole('dialog', { name: /save message/i });
+ const saveButton = dialog.getByRole('button', { name: /save/i });
+ await saveButton.click();
+ }
+
+ async copyMessageValue() {
+ await this.page.getByRole('button', { name: /copy value/i }).click();
+ }
+
+ async verifyClipboardContent(expectedContent: string) {
+ const clipboardText = await this.page.evaluate(() => navigator.clipboard.readText());
+ expect(clipboardText).toBe(expectedContent);
+ }
+
+ async fillQuickSearch(searchTerm: string) {
+ const searchInput = this.page.getByTestId('message-quick-search-input');
+ await searchInput.fill(searchTerm);
+ await this.page.keyboard.press('Enter');
+ }
+
+ async verifyPayloadContentVisible() {
+ await expect(this.page.getByTestId('payload-content')).toBeVisible({ timeout: 5000 });
+ }
+
+ /**
+ * Configuration tab operations
+ */
+ async verifyConfigurationGroupsVisible() {
+ await expect(this.page.getByTestId('config-group-table')).toBeVisible();
+ }
+
+ async verifyConfigurationGroup(groupName: string) {
+ await expect(this.page.locator('.configGroupTitle').filter({ hasText: groupName })).toBeVisible();
+ }
+
+ async getConfigurationGroups(): Promise {
+ return await this.page.locator('.configGroupTitle').allTextContents();
+ }
+
+ /**
+ * Breadcrumb navigation
+ */
+ async clickTopicsBreadcrumb() {
+ await this.page.getByRole('link', { name: 'Topics' }).first().click();
+ }
+
+ async verifyOnTopicsListPage() {
+ await expect(this.page).toHaveURL(/\/topics\/?(\?.*)?$/);
+ }
+
+ /**
+ * Produce message operations
+ */
+ async fillValueEditor(content: string) {
+ const valueEditor = this.page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
+ await valueEditor.click();
+ await this.page.keyboard.insertText(content);
+ }
+
+ async fillKeyEditor(content: string) {
+ const keyEditor = this.page.getByTestId('produce-key-editor').locator('.monaco-editor').first();
+ await keyEditor.click();
+ await this.page.keyboard.insertText(content);
+ }
+
+ async clickProduceButton() {
+ await this.page.getByTestId('produce-button').click();
+ }
+
+ async pasteIntoValueEditor(content: string) {
+ const monacoEditor = this.page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
+ await monacoEditor.click();
+ await this.page.evaluate(`navigator.clipboard.writeText("${content}")`);
+ await this.page.keyboard.press('Control+KeyV');
+ await this.page.keyboard.press('Meta+KeyV');
+ }
+
+ async verifyMessageProduced(message: string) {
+ await expect(this.page.getByRole('cell', { name: new RegExp(message, 'i') }).first()).toBeVisible({
+ timeout: 10_000,
+ });
+ }
+
+ async clickLoadAnywayButton() {
+ await this.page.getByTestId('load-anyway-button').click();
+ }
+
+ async verifyMessageInTopic(message: string) {
+ await expect(this.page.getByText(message)).toBeVisible();
+ }
+
+ /**
+ * High-level convenience methods
+ * These combine multiple operations for common workflows
+ */
+
+ /**
+ * Creates a topic with the given name through the UI
+ * Includes verification that the topic appears in the list
+ */
+ async createTopic(topicName: string) {
+ return await test.step('Create topic', async () => {
+ await this.goToTopicsList();
+ await this.clickCreateTopicButton();
+ await this.fillTopicName(topicName);
+ await this.submitTopicCreation();
+ await this.closeSuccessModal();
+ await this.verifyTopicInList(topicName);
+ });
+ }
+
+ /**
+ * Deletes a topic with the given name through the UI
+ * Includes verification that the topic is removed from the list
+ */
+ async deleteTopic(topicName: string) {
+ return await test.step('Delete topic', async () => {
+ await this.goToTopicsList();
+ await this.verifyTopicInList(topicName); // Verify topic exists
+ await this.page.getByTestId(`delete-topic-button-${topicName}`).click();
+ await this.page.getByTestId('delete-topic-confirm-button').click();
+ await expect(this.page.getByText('Topic Deleted')).toBeVisible();
+ await this.verifyTopicNotInList(topicName);
+ });
+ }
+
+ /**
+ * Produces a message to the specified topic through the UI
+ * Includes verification that the message appears in the topic
+ */
+ async produceMessage(topicName: string, message: string) {
+ return await test.step('Produce message', async () => {
+ await this.goToProduceRecord(topicName);
+ await this.fillValueEditor(message);
+ await this.clickProduceButton();
+ await this.verifyMessageProduced(message);
+ });
+ }
+}
diff --git a/frontend/tests/global-setup.mjs b/frontend/tests/global-setup.mjs
index fc2d0b1c8b..0f665651f1 100644
--- a/frontend/tests/global-setup.mjs
+++ b/frontend/tests/global-setup.mjs
@@ -149,6 +149,7 @@ schemaRegistry:
`;
const owlshop = await new GenericContainer('quay.io/cloudhut/owl-shop:master')
+ .withPlatform('linux/amd64')
.withNetwork(network)
.withNetworkAliases('owlshop')
.withEnvironment({
@@ -219,6 +220,7 @@ topic.creation.enable=false
let connect;
try {
connect = await new GenericContainer('docker.cloudsmith.io/redpanda/connectors-unsupported/connectors:latest')
+ .withPlatform('linux/amd64')
.withNetwork(network)
.withNetworkAliases('connect')
.withExposedPorts({ container: 8083, host: 18_083 })
@@ -568,8 +570,8 @@ async function cleanupOnFailure(state) {
}
}
-export default async function globalSetup(config) {
- const isEnterprise = config.metadata?.isEnterprise ?? false;
+export default async function globalSetup(config = {}) {
+ const isEnterprise = config?.metadata?.isEnterprise ?? false;
console.log('\n\n========================================');
console.log(`🚀 GLOBAL SETUP STARTED ${isEnterprise ? '(ENTERPRISE MODE)' : '(OSS MODE)'}`);
diff --git a/frontend/tests/global-teardown.mjs b/frontend/tests/global-teardown.mjs
index de31e68914..f9b4e2d2bb 100644
--- a/frontend/tests/global-teardown.mjs
+++ b/frontend/tests/global-teardown.mjs
@@ -11,8 +11,8 @@ const __dirname = dirname(__filename);
const getStateFile = (isEnterprise) =>
resolve(__dirname, isEnterprise ? '.testcontainers-state-enterprise.json' : '.testcontainers-state.json');
-export default async function globalTeardown(config) {
- const isEnterprise = config.metadata?.isEnterprise ?? false;
+export default async function globalTeardown(config = {}) {
+ const isEnterprise = config?.metadata?.isEnterprise ?? false;
const CONTAINER_STATE_FILE = getStateFile(isEnterprise);
console.log(`\n🛑 Stopping test environment ${isEnterprise ? '(ENTERPRISE MODE)' : '(OSS MODE)'}...`);
diff --git a/frontend/tests/roles.utils.ts b/frontend/tests/roles.utils.ts
deleted file mode 100644
index 42edb899f3..0000000000
--- a/frontend/tests/roles.utils.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { Page } from '@playwright/test';
-
-export const createRole = async (page: Page, { roleName }: { roleName: string }) => {
- await page.goto('/security/roles');
- await page.getByTestId('create-role-button').click({
- force: true,
- });
-
- await page.waitForURL('/security/roles/create', {
- waitUntil: 'domcontentloaded',
- });
- await page.getByLabel('Role name').fill(roleName);
- await page.getByTestId('roles-allow-all-operations').click({
- force: true,
- });
- await page.getByRole('button').getByText('Create').click({
- force: true,
- });
- return await page.waitForURL(`/security/roles/${roleName}/details`);
-};
-
-export const deleteRole = async (page: Page, { roleName }: { roleName: string }) => {
- await page.goto(`/security/roles/${roleName}/details`);
- await page.getByRole('button').getByText('Delete').click();
- await page.getByPlaceholder(`Type "${roleName}" to confirm`).fill(roleName);
- await page.getByTestId('test-delete-item').click({
- force: true,
- });
- return await page.waitForURL(`/security/roles/${roleName}/details`, {
- waitUntil: 'domcontentloaded',
- });
-};
diff --git a/frontend/tests/schema.utils.ts b/frontend/tests/schema.utils.ts
new file mode 100644
index 0000000000..24b3184ce4
--- /dev/null
+++ b/frontend/tests/schema.utils.ts
@@ -0,0 +1,177 @@
+import { expect, type Page, test } from '@playwright/test';
+
+export interface CreateSchemaOptions {
+ subjectName: string;
+ schemaFormat?: 'AVRO' | 'PROTOBUF' | 'JSON';
+ schemaText: string;
+ strategy?: 'TOPIC_NAME' | 'RECORD_NAME' | 'TOPIC_RECORD_NAME' | 'CUSTOM';
+ keyOrValue?: 'KEY' | 'VALUE';
+}
+
+export const createSchema = async (
+ page: Page,
+ { subjectName, schemaFormat = 'AVRO', schemaText, strategy = 'CUSTOM', keyOrValue }: CreateSchemaOptions
+) => {
+ return await test.step(`Create schema: ${subjectName}`, async () => {
+ await page.goto('/schema-registry');
+ await page.getByRole('button', { name: 'Create new schema' }).click();
+
+ // Wait for create page to load
+ await expect(page).toHaveURL('/schema-registry/create');
+
+ // Select schema format
+ if (schemaFormat !== 'AVRO') {
+ await page.getByTestId('schema-format-select').click();
+ await page.getByText(schemaFormat).click();
+ }
+
+ // Select naming strategy
+ await page.getByTestId('naming-strategy-select').click();
+ await page.getByText(strategy).click();
+
+ // If strategy requires key/value selection
+ if (strategy !== 'CUSTOM' && keyOrValue) {
+ await page.getByLabel(keyOrValue === 'KEY' ? 'Key' : 'Value').check();
+ }
+
+ // Fill subject name for CUSTOM strategy
+ if (strategy === 'CUSTOM') {
+ await page.getByTestId('subject-name-input').fill(subjectName);
+ }
+
+ // Fill schema text in Monaco editor
+ const editor = page.locator('.monaco-editor').first();
+ await editor.click();
+ await page.keyboard.press('Control+A');
+ await page.keyboard.press('Meta+A'); // For Mac
+ await page.keyboard.press('Backspace');
+ await page.keyboard.insertText(schemaText);
+
+ // Validate schema
+ await page.getByRole('button', { name: 'Validate' }).click();
+ await expect(page.getByText('Schema is valid')).toBeVisible({ timeout: 5000 });
+
+ // Create schema
+ await page.getByRole('button', { name: 'Create' }).click();
+ await expect(page.getByText('Schema created successfully')).toBeVisible({ timeout: 5000 });
+ });
+};
+
+export const deleteSchema = async (
+ page: Page,
+ { subjectName, permanent = false }: { subjectName: string; permanent?: boolean }
+) => {
+ return await test.step(`Delete schema: ${subjectName} (${permanent ? 'permanent' : 'soft'})`, async () => {
+ await page.goto('/schema-registry');
+
+ // Find and click delete button for the schema
+ const row = page.getByTestId('schema-registry-table-name').filter({ hasText: subjectName });
+ await expect(row).toBeVisible();
+
+ const deleteButton = row.locator('..').locator('button[aria-label*="delete"], button:has(svg)').last();
+ await deleteButton.click();
+
+ // Confirm deletion in modal
+ const confirmButton = page.getByRole('button', { name: permanent ? 'Permanently Delete' : 'Delete' });
+ await confirmButton.click();
+
+ await expect(page.getByText(permanent ? 'Subject permanently deleted' : 'Subject soft-deleted')).toBeVisible();
+ });
+};
+
+export const addSchemaVersion = async (
+ page: Page,
+ { subjectName, schemaText }: { subjectName: string; schemaText: string }
+) => {
+ return await test.step(`Add version to schema: ${subjectName}`, async () => {
+ await page.goto(`/schema-registry/subjects/${encodeURIComponent(subjectName)}?version=latest`);
+
+ // Click add version button
+ await page.getByRole('button', { name: 'Add version' }).click();
+ await expect(page).toHaveURL(
+ new RegExp(`/schema-registry/subjects/${encodeURIComponent(subjectName)}/add-version`)
+ );
+
+ // Update schema text in Monaco editor
+ const editor = page.locator('.monaco-editor').first();
+ await editor.click();
+ await page.keyboard.press('Control+A');
+ await page.keyboard.press('Meta+A'); // For Mac
+ await page.keyboard.press('Backspace');
+ await page.keyboard.insertText(schemaText);
+
+ // Validate schema
+ await page.getByRole('button', { name: 'Validate' }).click();
+ await expect(page.getByText('Schema is valid')).toBeVisible({ timeout: 5000 });
+
+ // Create new version
+ await page.getByRole('button', { name: 'Create' }).click();
+ await expect(page.getByText('Schema version created successfully')).toBeVisible({ timeout: 5000 });
+ });
+};
+
+export const sampleAvroSchema = (recordName: string) => {
+ return JSON.stringify(
+ {
+ type: 'record',
+ name: recordName,
+ namespace: 'com.example.test',
+ fields: [
+ { name: 'id', type: 'long' },
+ { name: 'name', type: 'string' },
+ { name: 'email', type: 'string' },
+ ],
+ },
+ null,
+ 2
+ );
+};
+
+export const sampleAvroSchemaV2 = (recordName: string) => {
+ return JSON.stringify(
+ {
+ type: 'record',
+ name: recordName,
+ namespace: 'com.example.test',
+ fields: [
+ { name: 'id', type: 'long' },
+ { name: 'name', type: 'string' },
+ { name: 'email', type: 'string' },
+ { name: 'phone', type: ['null', 'string'], default: null },
+ ],
+ },
+ null,
+ 2
+ );
+};
+
+export const sampleProtobufSchema = (messageName: string) => {
+ return `syntax = "proto3";
+
+package com.example.test;
+
+message ${messageName} {
+ int64 id = 1;
+ string name = 2;
+ string email = 3;
+}
+`;
+};
+
+export const sampleJsonSchema = (title: string) => {
+ return JSON.stringify(
+ {
+ $schema: 'http://json-schema.org/draft-07/schema#',
+ title: title,
+ type: 'object',
+ properties: {
+ id: { type: 'number' },
+ name: { type: 'string' },
+ email: { type: 'string', format: 'email' },
+ },
+ required: ['id', 'name', 'email'],
+ },
+ null,
+ 2
+ );
+};
diff --git a/frontend/tests/seed.spec.ts b/frontend/tests/seed.spec.ts
new file mode 100644
index 0000000000..8c2134a8ab
--- /dev/null
+++ b/frontend/tests/seed.spec.ts
@@ -0,0 +1,23 @@
+import { expect, test } from '@playwright/test';
+
+test.describe('Seed Test for Redpanda Console', () => {
+ test('seed - application setup and basic navigation', async ({ page }) => {
+ // Navigate to Redpanda Console homepage
+ await page.goto('/');
+
+ // Verify application loaded successfully
+ await expect(page).toHaveTitle(/Redpanda/);
+
+ // Verify version title is visible (basic component check)
+ await expect(page.getByTestId('versionTitle')).toBeVisible();
+
+ // This seed test establishes:
+ // - Base URL navigation works
+ // - Application renders correctly
+ // - Basic UI elements are visible
+ // - Test environment is properly configured
+ //
+ // The planner agent will use this context to understand
+ // your application structure and create test scenarios.
+ });
+});
diff --git a/frontend/tests/topic.utils.ts b/frontend/tests/topic.utils.ts
deleted file mode 100644
index 69b9de2a57..0000000000
--- a/frontend/tests/topic.utils.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { expect, type Page, test } from '@playwright/test';
-
-export const createTopic = async (page: Page, { topicName }: { topicName: string }) => {
- return await test.step('Create topic', async () => {
- await page.goto('/topics');
- await page.getByTestId('create-topic-button').click();
- await page.getByTestId('topic-name').fill(topicName);
- await page.getByTestId('onOk-button').click();
- await page.getByRole('button', { name: 'Close' }).click(); // Close success dialog
- await expect(page.getByRole('link', { name: topicName })).toBeVisible();
- });
-};
-
-export const deleteTopic = async (page: Page, { topicName }: { topicName: string }) => {
- return await test.step('Delete topic', async () => {
- await page.goto('/topics');
- await expect(page.getByRole('link', { name: topicName })).toBeVisible(); // Verify topic exists
- await page.getByTestId(`delete-topic-button-${topicName}`).click();
- await page.getByTestId('delete-topic-confirm-button').click();
- await expect(page.getByText('Topic Deleted')).toBeVisible();
- await expect(page.getByRole('link', { name: topicName })).not.toBeVisible();
- });
-};
-
-export const produceMessage = async (page: Page, { topicName, message }: { topicName: string; message: string }) => {
- return await test.step('Produce message', async () => {
- await page.goto(`/topics/${topicName}/produce-record`);
- const valueMonacoEditor = page.getByTestId('produce-value-editor').locator('.monaco-editor').first();
- await valueMonacoEditor.click(); // Focus the editor
- await page.keyboard.insertText(message);
- await page.getByTestId('produce-button').click();
- const messageValueCell = page.getByRole('cell', { name: new RegExp(message, 'i') }).first();
- await expect(messageValueCell).toBeVisible();
- });
-};
diff --git a/frontend/tests/users.utils.ts b/frontend/tests/users.utils.ts
deleted file mode 100644
index ac59f8e71f..0000000000
--- a/frontend/tests/users.utils.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { Page } from '@playwright/test';
-
-export const createUser = async (page: Page, { username }: { username: string }) => {
- await page.goto('/security/users');
-
- await page.getByTestId('create-user-button').click();
-
- await page.waitForURL('/security/users/create');
- await page.getByLabel('Username').fill(username);
- return await page.getByRole('button').getByText('Create').click({
- force: true,
- });
-};
-
-export const deleteUser = async (page: Page, { username }: { username: string }) => {
- await page.goto(`/security/users/${username}/details`);
- await page.getByRole('button').getByText('Delete').click();
- await page.getByPlaceholder(`Type "${username}" to confirm`).fill(username);
- return await page.getByTestId('test-delete-item').click({
- force: true,
- });
-};