diff --git a/.github/workflows/bashlib.sh b/.github/workflows/bashlib.sh
index deb56ab649e..df4fb99c103 100644
--- a/.github/workflows/bashlib.sh
+++ b/.github/workflows/bashlib.sh
@@ -182,6 +182,76 @@ cypress-run-all() {
kill $flaskProcessId
}
+playwright-install() {
+ cd "$GITHUB_WORKSPACE/superset-frontend"
+
+ say "::group::Install Playwright browsers"
+ npx playwright install --with-deps chromium
+ # Create output directories for test results and debugging
+ mkdir -p playwright-results
+ mkdir -p test-results
+ say "::endgroup::"
+}
+
+playwright-run() {
+ local APP_ROOT=$1
+
+ # Start Flask from the project root (same as Cypress)
+ cd "$GITHUB_WORKSPACE"
+ local flasklog="${HOME}/flask-playwright.log"
+ local port=8081
+ PLAYWRIGHT_BASE_URL="http://localhost:${port}"
+ if [ -n "$APP_ROOT" ]; then
+ export SUPERSET_APP_ROOT=$APP_ROOT
+ PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}${APP_ROOT}/
+ fi
+ export PLAYWRIGHT_BASE_URL
+
+ nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 /dev/null || true" EXIT
+
+ # Wait for server to be ready with health check
+ local timeout=60
+ say "Waiting for Flask server to start on port $port..."
+ while [ $timeout -gt 0 ]; do
+ if curl -f ${PLAYWRIGHT_BASE_URL}/health >/dev/null 2>&1; then
+ say "Flask server is ready"
+ break
+ fi
+ sleep 1
+ timeout=$((timeout - 1))
+ done
+
+ if [ $timeout -eq 0 ]; then
+ echo "::error::Flask server failed to start within 60 seconds"
+ echo "::group::Flask startup log"
+ cat "$flasklog"
+ echo "::endgroup::"
+ return 1
+ fi
+
+ # Change to frontend directory for Playwright execution
+ cd "$GITHUB_WORKSPACE/superset-frontend"
+
+ say "::group::Run Playwright tests"
+ echo "Running Playwright with baseURL: ${PLAYWRIGHT_BASE_URL}"
+ npx playwright test auth/login --reporter=github --output=playwright-results
+ local status=$?
+ say "::endgroup::"
+
+ # After job is done, print out Flask log for debugging
+ echo "::group::Flask log for Playwright run"
+ cat "$flasklog"
+ echo "::endgroup::"
+ # make sure the program exits
+ kill $flaskProcessId
+
+ return $status
+}
+
eyes-storybook-dependencies() {
say "::group::install eyes-storyook dependencies"
sudo apt-get update -y && sudo apt-get -y install gconf-service ca-certificates libxshmfence-dev fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libglib2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libnspr4 libnss3 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release xdg-utils libappindicator1
diff --git a/.github/workflows/superset-playwright.yml b/.github/workflows/superset-playwright.yml
new file mode 100644
index 00000000000..b049d3589ad
--- /dev/null
+++ b/.github/workflows/superset-playwright.yml
@@ -0,0 +1,141 @@
+name: Playwright E2E Tests
+
+on:
+ push:
+ branches:
+ - "master"
+ - "[0-9].[0-9]*"
+ pull_request:
+ types: [synchronize, opened, reopened, ready_for_review]
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: 'The branch or tag to checkout'
+ required: false
+ default: ''
+ pr_id:
+ description: 'The pull request ID to checkout'
+ required: false
+ default: ''
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ playwright-tests:
+ runs-on: ubuntu-22.04
+ # Allow workflow to succeed even if tests fail during shadow mode
+ continue-on-error: true
+ permissions:
+ contents: read
+ pull-requests: read
+ strategy:
+ fail-fast: false
+ matrix:
+ browser: ["chromium"]
+ app_root: ["", "/app/prefix"]
+ env:
+ SUPERSET_ENV: development
+ SUPERSET_CONFIG: tests.integration_tests.superset_test_config
+ SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
+ PYTHONPATH: ${{ github.workspace }}
+ REDIS_PORT: 16379
+ GITHUB_TOKEN: ${{ github.token }}
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: superset
+ POSTGRES_PASSWORD: superset
+ ports:
+ - 15432:5432
+ redis:
+ image: redis:7-alpine
+ ports:
+ - 16379:6379
+ steps:
+ # -------------------------------------------------------
+ # Conditional checkout based on context (same as Cypress workflow)
+ - name: Checkout for push or pull_request event
+ if: github.event_name == 'push' || github.event_name == 'pull_request'
+ uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ submodules: recursive
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ - name: Checkout using ref (workflow_dispatch)
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
+ uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ ref: ${{ github.event.inputs.ref }}
+ submodules: recursive
+ - name: Checkout using PR ID (workflow_dispatch)
+ if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
+ uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
+ submodules: recursive
+ # -------------------------------------------------------
+ - name: Check for file changes
+ id: check
+ uses: ./.github/actions/change-detector/
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Setup Python
+ uses: ./.github/actions/setup-backend/
+ if: steps.check.outputs.python || steps.check.outputs.frontend
+ - name: Setup postgres
+ if: steps.check.outputs.python || steps.check.outputs.frontend
+ uses: ./.github/actions/cached-dependencies
+ with:
+ run: setup-postgres
+ - name: Import test data
+ if: steps.check.outputs.python || steps.check.outputs.frontend
+ uses: ./.github/actions/cached-dependencies
+ with:
+ run: testdata
+ - name: Setup Node.js
+ if: steps.check.outputs.python || steps.check.outputs.frontend
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: './superset-frontend/.nvmrc'
+ - name: Install npm dependencies
+ if: steps.check.outputs.python || steps.check.outputs.frontend
+ uses: ./.github/actions/cached-dependencies
+ with:
+ run: npm-install
+ - name: Build javascript packages
+ if: steps.check.outputs.python || steps.check.outputs.frontend
+ uses: ./.github/actions/cached-dependencies
+ with:
+ run: build-instrumented-assets
+ - name: Install Playwright
+ if: steps.check.outputs.python || steps.check.outputs.frontend
+ uses: ./.github/actions/cached-dependencies
+ with:
+ run: playwright-install
+ - name: Run Playwright
+ if: steps.check.outputs.python || steps.check.outputs.frontend
+ uses: ./.github/actions/cached-dependencies
+ env:
+ NODE_OPTIONS: "--max-old-space-size=4096"
+ with:
+ run: playwright-run ${{ matrix.app_root }}
+ - name: Set safe app root
+ if: failure()
+ id: set-safe-app-root
+ run: |
+ APP_ROOT="${{ matrix.app_root }}"
+ SAFE_APP_ROOT=${APP_ROOT//\//_}
+ echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
+ - name: Upload Playwright Artifacts
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ path: |
+ ${{ github.workspace }}/superset-frontend/playwright-results/
+ ${{ github.workspace }}/superset-frontend/test-results/
+ name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
diff --git a/LLMS.md b/LLMS.md
index 6acc454007f..05576b8a051 100644
--- a/LLMS.md
+++ b/LLMS.md
@@ -15,8 +15,9 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
### Testing Strategy Migration
- **Prefer unit tests** over integration tests
-- **Prefer integration tests** over Cypress end-to-end tests
-- **Cypress is last resort** - Actively moving away from Cypress
+- **Prefer integration tests** over end-to-end tests
+- **Use Playwright for E2E tests** - Migrating from Cypress
+- **Cypress is deprecated** - Will be removed once migration is completed
- **Use Jest + React Testing Library** for component testing
- **Use `test()` instead of `describe()`** - Follow [avoid nesting when testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing) principles
@@ -107,6 +108,18 @@ superset/
npm run test # All tests
npm run test -- filename.test.tsx # Single file
+# E2E Tests (Playwright - NEW)
+npm run playwright:test # All Playwright tests
+npm run playwright:ui # Interactive UI mode
+npm run playwright:headed # See browser during tests
+npx playwright test tests/auth/login.spec.ts # Single file
+npm run playwright:debug tests/auth/login.spec.ts # Debug specific file
+
+# E2E Tests (Cypress - DEPRECATED)
+cd superset-frontend/cypress-base
+npm run cypress-run-chrome # All Cypress tests (headless)
+npm run cypress-debug # Interactive Cypress UI
+
# Backend
pytest # All tests
pytest tests/unit_tests/specific_test.py # Single file
diff --git a/docs/docs/contributing/development.mdx b/docs/docs/contributing/development.mdx
index 6e22055d0d4..c39502c3b61 100644
--- a/docs/docs/contributing/development.mdx
+++ b/docs/docs/contributing/development.mdx
@@ -631,7 +631,7 @@ can find all of the workflows and other assets under the `.github/` folder. This
- running the backend unit test suites (`tests/`)
- running the frontend test suites (`superset-frontend/src/**.*.test.*`)
-- running our Cypress end-to-end tests (`superset-frontend/cypress-base/`)
+- running our Playwright end-to-end tests (`superset-frontend/playwright/`) and legacy Cypress tests (`superset-frontend/cypress-base/`)
- linting the codebase, including all Python, Typescript and Javascript, yaml and beyond
- checking for all sorts of other rules conventions
diff --git a/docs/docs/contributing/howtos.mdx b/docs/docs/contributing/howtos.mdx
index e243a1fcf5c..5f11d7deb3c 100644
--- a/docs/docs/contributing/howtos.mdx
+++ b/docs/docs/contributing/howtos.mdx
@@ -225,21 +225,57 @@ npm run test -- path/to/file.js
### E2E Integration Testing
-For E2E testing, we recommend that you use a `docker compose` backend
+**Note: We are migrating from Cypress to Playwright. Use Playwright for new tests.**
+
+#### Playwright (Recommended - NEW)
+
+For E2E testing with Playwright, use the same `docker compose` backend:
```bash
CYPRESS_CONFIG=true docker compose up --build
```
-`docker compose` will get to work and expose a Cypress-ready Superset app.
-This app uses a different database schema (`superset_cypress`) to keep it isolated from
-your other dev environment(s), a specific set of examples, and a set of configurations that
-aligns with the expectations within the end-to-end tests. Also note that it's served on a
-different port than the default port for the backend (`8088`).
-Now in another terminal, let's get ready to execute some Cypress commands. First, tell cypress
-to connect to the Cypress-ready Superset backend.
+The backend setup is identical - this exposes a test-ready Superset app on port 8081 with isolated database schema (`superset_cypress`), test data, and configurations.
+Now in another terminal, run Playwright tests:
+
+```bash
+# Navigate to frontend directory (Playwright config is here)
+cd superset-frontend
+
+# Run all Playwright tests
+npm run playwright:test
+# or: npx playwright test
+
+# Run with interactive UI for debugging
+npm run playwright:ui
+# or: npx playwright test --ui
+
+# Run in headed mode (see browser)
+npm run playwright:headed
+# or: npx playwright test --headed
+
+# Run specific test file
+npx playwright test tests/auth/login.spec.ts
+
+# Run with debug mode (step through tests)
+npm run playwright:debug tests/auth/login.spec.ts
+# or: npx playwright test --debug tests/auth/login.spec.ts
+
+# Generate test report
+npx playwright show-report
```
+
+Configuration is in `superset-frontend/playwright.config.ts`. Base URL is automatically set to `http://localhost:8088` but will use `PLAYWRIGHT_BASE_URL` if provided.
+
+#### Cypress (DEPRECATED - will be removed in Phase 5)
+
+:::warning
+Cypress is being phased out in favor of Playwright. Use Playwright for all new tests.
+:::
+
+```bash
+# Set base URL for Cypress
CYPRESS_BASE_URL=http://localhost:8081
```
diff --git a/superset-frontend/.eslintrc.js b/superset-frontend/.eslintrc.js
index 8e442501cb0..58440989bd3 100644
--- a/superset-frontend/.eslintrc.js
+++ b/superset-frontend/.eslintrc.js
@@ -323,6 +323,7 @@ module.exports = {
'*.stories.tsx',
'*.stories.jsx',
'fixtures.*',
+ 'playwright/**/*',
],
excludedFiles: 'cypress-base/cypress/**/*',
plugins: ['jest', 'jest-dom', 'no-only-tests', 'testing-library'],
@@ -397,6 +398,13 @@ module.exports = {
'react/no-void-elements': 0,
},
},
+ {
+ files: ['playwright/**/*'],
+ rules: {
+ 'import/no-unresolved': 0, // Playwright is not installed in main build
+ 'import/no-extraneous-dependencies': 0, // Playwright is not installed in main build
+ },
+ },
],
// eslint-disable-next-line no-dupe-keys
rules: {
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index 7df6870bbfb..ac8ebeb1f3f 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -159,6 +159,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
+ "@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -10109,6 +10110,22 @@
"url": "https://opencollective.com/unts"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
+ "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.55.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -45517,6 +45534,53 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/playwright": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
+ "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.55.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
+ "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/png-async": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/png-async/-/png-async-0.9.4.tgz",
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index ea25fdda566..3372dc2c29f 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -63,6 +63,11 @@
"plugins:release-conventional": "npm run prune && npm run plugins:build && lerna publish --conventional-commits --create-release github --yes",
"plugins:release-from-tag": "npm run prune && npm run plugins:build && lerna publish from-package --yes",
"plugins:storybook": "cd packages/superset-ui-demo && npm run storybook",
+ "playwright:test": "playwright test",
+ "playwright:ui": "playwright test --ui",
+ "playwright:headed": "playwright test --headed",
+ "playwright:debug": "playwright test --debug",
+ "playwright:report": "playwright show-report",
"prettier": "npm run _prettier -- --write",
"prettier-check": "npm run _prettier -- --check",
"prod": "npm run build",
@@ -227,6 +232,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
+ "@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
diff --git a/superset-frontend/playwright.config.ts b/superset-frontend/playwright.config.ts
new file mode 100644
index 00000000000..f8170459c23
--- /dev/null
+++ b/superset-frontend/playwright.config.ts
@@ -0,0 +1,90 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+///
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ // Test directory
+ testDir: './playwright/tests',
+
+ // Timeout settings
+ timeout: 30000,
+ expect: { timeout: 8000 },
+
+ // Parallel execution
+ fullyParallel: true,
+ workers: process.env.CI ? 2 : 1,
+
+ // Retry logic - 2 retries in CI, 0 locally
+ retries: process.env.CI ? 2 : 0,
+
+ // Reporter configuration - multiple reporters for better visibility
+ reporter: process.env.CI
+ ? [
+ ['github'], // GitHub Actions annotations
+ ['list'], // Detailed output with summary table
+ ['html', { outputFolder: 'playwright-report', open: 'never' }], // Interactive report
+ ['json', { outputFile: 'test-results/results.json' }], // Machine-readable
+ ]
+ : [
+ ['list'], // Shows summary table locally
+ ['html', { outputFolder: 'playwright-report', open: 'on-failure' }], // Auto-open on failure
+ ],
+
+ // Global test setup
+ use: {
+ // Use environment variable for base URL in CI, default to localhost:8088 for local
+ baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088',
+
+ // Browser settings
+ headless: !!process.env.CI,
+
+ viewport: { width: 1280, height: 1024 },
+
+ // Screenshots and videos on failure
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+
+ // Trace collection for debugging
+ trace: 'retain-on-failure',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ browserName: 'chromium',
+ testIdAttribute: 'data-test',
+ },
+ },
+ ],
+
+ // Web server setup - disabled in CI (Flask started separately in workflow)
+ webServer: process.env.CI
+ ? undefined
+ : {
+ command: 'curl -f http://localhost:8088/health',
+ url: 'http://localhost:8088/health',
+ reuseExistingServer: true,
+ timeout: 5000,
+ },
+});
diff --git a/superset-frontend/playwright/README.md b/superset-frontend/playwright/README.md
new file mode 100644
index 00000000000..f5e67219cf7
--- /dev/null
+++ b/superset-frontend/playwright/README.md
@@ -0,0 +1,218 @@
+
+
+# Playwright E2E Tests for Superset
+
+This directory contains Playwright end-to-end tests for Apache Superset, designed as a replacement for the existing Cypress tests during the migration to Playwright.
+
+## Architecture
+
+```
+playwright/
+├── components/core/ # Reusable UI components
+├── pages/ # Page Object Models
+├── tests/ # Test files organized by feature
+├── utils/ # Shared constants and utilities
+└── README.md # This file
+```
+
+## Design Principles
+
+We follow **YAGNI** (You Aren't Gonna Need It), **DRY** (Don't Repeat Yourself), and **KISS** (Keep It Simple, Stupid) principles:
+
+- Build only what's needed now
+- Reuse existing patterns and components
+- Keep solutions simple and maintainable
+
+## Component Architecture
+
+### Core Components (`components/core/`)
+
+Reusable UI interaction classes for common elements:
+
+- **Form**: Container with properly scoped child element access
+- **Input**: Supports `fill()`, `type()`, and `pressSequentially()` methods
+- **Button**: Standard click, hover, focus interactions
+
+**Usage Example:**
+```typescript
+import { Form } from '../components/core';
+
+const loginForm = new Form(page, '[data-test="login-form"]');
+const usernameInput = loginForm.getInput('[data-test="username-input"]');
+await usernameInput.fill('admin');
+```
+
+### Page Objects (`pages/`)
+
+Each page object encapsulates:
+- **Actions**: What you can do on the page
+- **Queries**: Information you can get from the page
+- **Selectors**: Centralized in private static SELECTORS constant
+- **NO Assertions**: Keep assertions in test files
+
+**Page Object Pattern:**
+```typescript
+export class AuthPage {
+ // Selectors centralized in the page object
+ private static readonly SELECTORS = {
+ LOGIN_FORM: '[data-test="login-form"]',
+ USERNAME_INPUT: '[data-test="username-input"]',
+ } as const;
+
+ // Actions - what you can do
+ async loginWithCredentials(username: string, password: string) { }
+
+ // Queries - information you can get
+ async getCurrentUrl(): Promise { }
+
+ // NO assertions - those belong in tests
+}
+```
+
+### Tests (`tests/`)
+
+Organized by feature/area (auth, dashboard, charts, etc.):
+- Use page objects for actions
+- Keep assertions in test files
+- Import shared constants from `utils/`
+
+**Test Pattern:**
+```typescript
+import { test, expect } from '@playwright/test';
+import { AuthPage } from '../../pages/AuthPage';
+import { LOGIN } from '../../utils/urls';
+
+test('should login with correct credentials', async ({ page }) => {
+ const authPage = new AuthPage(page);
+ await authPage.goto();
+ await authPage.loginWithCredentials('admin', 'general');
+
+ // Assertions belong in tests, not page objects
+ expect(await authPage.getCurrentUrl()).not.toContain(LOGIN);
+});
+```
+
+### Utilities (`utils/`)
+
+Shared constants and utilities:
+- **urls.ts**: URL paths and request patterns
+- Keep flat exports (no premature namespacing)
+
+## Contributing Guidelines
+
+### Adding New Tests
+
+1. **Check existing components** before creating new ones
+2. **Use page objects** for page interactions
+3. **Keep assertions in tests**, not page objects
+4. **Follow naming conventions**: `feature.spec.ts`
+
+### Adding New Components
+
+1. **Follow YAGNI**: Only build what's immediately needed
+2. **Use Locator-based scoping** for proper element isolation
+3. **Support both string selectors and Locator objects** via constructor overloads
+4. **Add to `components/core/index.ts`** for easy importing
+
+### Adding New Page Objects
+
+1. **Centralize selectors** in private static SELECTORS constant
+2. **Import shared constants** from `utils/urls.ts`
+3. **Actions and queries only** - no assertions
+4. **Use existing components** for DOM interactions
+
+## Running Tests
+
+```bash
+# Run all tests
+npm run playwright:test
+# or: npx playwright test
+
+# Run specific test file
+npx playwright test tests/auth/login.spec.ts
+
+# Run with UI mode for debugging
+npm run playwright:ui
+# or: npx playwright test --ui
+
+# Run in headed mode (see browser)
+npm run playwright:headed
+# or: npx playwright test --headed
+
+# Debug specific test file
+npm run playwright:debug tests/auth/login.spec.ts
+# or: npx playwright test --debug tests/auth/login.spec.ts
+```
+
+## Test Reports
+
+Playwright generates multiple reports for better visibility:
+
+```bash
+# View interactive HTML report (opens automatically on failure)
+npm run playwright:report
+# or: npx playwright show-report
+
+# View test trace for debugging failures
+npx playwright show-trace test-results/[test-name]/trace.zip
+```
+
+### Report Types
+
+- **List Reporter**: Shows progress and summary table in terminal
+- **HTML Report**: Interactive web interface with screenshots, videos, and traces
+- **JSON Report**: Machine-readable format in `test-results/results.json`
+- **GitHub Actions**: Annotations in CI for failed tests
+
+### Debugging Failed Tests
+
+When tests fail, Playwright automatically captures:
+- **Screenshots** at the point of failure
+- **Videos** of the entire test run
+- **Traces** with timeline and network activity
+- **Error context** with detailed debugging information
+
+All debugging artifacts are available in the HTML report for easy analysis.
+
+## Configuration
+
+- **Config**: `playwright.config.ts` - matches Cypress settings
+- **Base URL**: `http://localhost:8088` (assumes Superset running)
+- **Browsers**: Chrome only for Phase 1 (YAGNI)
+- **Retries**: 2 in CI, 0 locally (matches Cypress)
+
+## Migration from Cypress
+
+When porting Cypress tests:
+
+1. **Port the logic**, not the implementation
+2. **Use page objects** instead of inline selectors
+3. **Replace `cy.intercept/cy.wait`** with `page.waitForRequest()`
+4. **Use shared constants** from `utils/urls.ts`
+5. **Follow the established patterns** shown in `tests/auth/login.spec.ts`
+
+## Best Practices
+
+- **Centralize selectors** in page objects
+- **Centralize URLs** in `utils/urls.ts`
+- **Use meaningful test descriptions**
+- **Keep page objects action-focused**
+- **Put assertions in tests, not page objects**
+- **Follow the existing patterns** for consistency
diff --git a/superset-frontend/playwright/components/core/Button.ts b/superset-frontend/playwright/components/core/Button.ts
new file mode 100644
index 00000000000..61f2d8d317e
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Button.ts
@@ -0,0 +1,119 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Locator, Page } from '@playwright/test';
+
+export class Button {
+ private readonly locator: Locator;
+
+ constructor(page: Page, selector: string);
+
+ constructor(page: Page, locator: Locator);
+
+ constructor(page: Page, selectorOrLocator: string | Locator) {
+ if (typeof selectorOrLocator === 'string') {
+ this.locator = page.locator(selectorOrLocator);
+ } else {
+ this.locator = selectorOrLocator;
+ }
+ }
+
+ /**
+ * Gets the button element locator
+ */
+ get element(): Locator {
+ return this.locator;
+ }
+
+ /**
+ * Clicks the button
+ * @param options - Optional click options
+ */
+ async click(options?: {
+ timeout?: number;
+ force?: boolean;
+ delay?: number;
+ button?: 'left' | 'right' | 'middle';
+ }): Promise {
+ await this.element.click(options);
+ }
+
+ /**
+ * Gets the button text content
+ */
+ async getText(): Promise {
+ return (await this.element.textContent()) ?? '';
+ }
+
+ /**
+ * Gets a specific attribute value from the button
+ * @param attribute - The attribute name to retrieve
+ */
+ async getAttribute(attribute: string): Promise {
+ return this.element.getAttribute(attribute);
+ }
+
+ /**
+ * Checks if the button is visible
+ */
+ async isVisible(): Promise {
+ return this.element.isVisible();
+ }
+
+ /**
+ * Checks if the button is enabled
+ */
+ async isEnabled(): Promise {
+ return this.element.isEnabled();
+ }
+
+ /**
+ * Checks if the button is disabled
+ */
+ async isDisabled(): Promise {
+ return this.element.isDisabled();
+ }
+
+ /**
+ * Hovers over the button
+ * @param options - Optional hover options
+ */
+ async hover(options?: { timeout?: number; force?: boolean }): Promise {
+ await this.element.hover(options);
+ }
+
+ /**
+ * Focuses on the button
+ */
+ async focus(): Promise {
+ await this.element.focus();
+ }
+
+ /**
+ * Double clicks the button
+ * @param options - Optional click options
+ */
+ async doubleClick(options?: {
+ timeout?: number;
+ force?: boolean;
+ delay?: number;
+ }): Promise {
+ await this.element.dblclick(options);
+ }
+}
diff --git a/superset-frontend/playwright/components/core/Form.ts b/superset-frontend/playwright/components/core/Form.ts
new file mode 100644
index 00000000000..31970e59e24
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Form.ts
@@ -0,0 +1,110 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Locator, Page } from '@playwright/test';
+import { Input } from './Input';
+import { Button } from './Button';
+
+export class Form {
+ private readonly page: Page;
+
+ private readonly locator: Locator;
+
+ constructor(page: Page, selector: string);
+
+ constructor(page: Page, locator: Locator);
+
+ constructor(page: Page, selectorOrLocator: string | Locator) {
+ this.page = page;
+ if (typeof selectorOrLocator === 'string') {
+ this.locator = page.locator(selectorOrLocator);
+ } else {
+ this.locator = selectorOrLocator;
+ }
+ }
+
+ /**
+ * Gets the form element locator
+ */
+ get element(): Locator {
+ return this.locator;
+ }
+
+ /**
+ * Gets an input field within the form (properly scoped)
+ * @param inputSelector - Selector for the input field
+ */
+ getInput(inputSelector: string): Input {
+ const scopedLocator = this.locator.locator(inputSelector);
+ return new Input(this.page, scopedLocator);
+ }
+
+ /**
+ * Gets a button within the form (properly scoped)
+ * @param buttonSelector - Selector for the button
+ */
+ getButton(buttonSelector: string): Button {
+ const scopedLocator = this.locator.locator(buttonSelector);
+ return new Button(this.page, scopedLocator);
+ }
+
+ /**
+ * Checks if the form is visible
+ */
+ async isVisible(): Promise {
+ return this.locator.isVisible();
+ }
+
+ /**
+ * Submits the form (triggers submit event)
+ */
+ async submit(): Promise {
+ await this.locator.evaluate((form: HTMLElement) => {
+ if (form instanceof HTMLFormElement) {
+ form.submit();
+ }
+ });
+ }
+
+ /**
+ * Waits for the form to be visible
+ * @param options - Optional wait options
+ */
+ async waitForVisible(options?: { timeout?: number }): Promise {
+ await this.locator.waitFor({ state: 'visible', ...options });
+ }
+
+ /**
+ * Gets all form data as key-value pairs
+ * Useful for validation and debugging
+ */
+ async getFormData(): Promise> {
+ return this.locator.evaluate((form: HTMLElement) => {
+ if (form instanceof HTMLFormElement) {
+ const formData = new FormData(form);
+ const result: Record = {};
+ formData.forEach((value, key) => {
+ result[key] = value.toString();
+ });
+ return result;
+ }
+ return {};
+ });
+ }
+}
diff --git a/superset-frontend/playwright/components/core/Input.ts b/superset-frontend/playwright/components/core/Input.ts
new file mode 100644
index 00000000000..ff0308a4c6c
--- /dev/null
+++ b/superset-frontend/playwright/components/core/Input.ts
@@ -0,0 +1,111 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Locator, Page } from '@playwright/test';
+
+export class Input {
+ private readonly locator: Locator;
+
+ constructor(page: Page, selector: string);
+
+ constructor(page: Page, locator: Locator);
+
+ constructor(page: Page, selectorOrLocator: string | Locator) {
+ if (typeof selectorOrLocator === 'string') {
+ this.locator = page.locator(selectorOrLocator);
+ } else {
+ this.locator = selectorOrLocator;
+ }
+ }
+
+ /**
+ * Gets the input element locator
+ */
+ get element(): Locator {
+ return this.locator;
+ }
+
+ /**
+ * Fast fill - clears the input and sets the value directly
+ * @param value - The value to fill
+ * @param options - Optional fill options
+ */
+ async fill(
+ value: string,
+ options?: { timeout?: number; force?: boolean },
+ ): Promise {
+ await this.element.fill(value, options);
+ }
+
+ /**
+ * Types text character by character (simulates real typing)
+ * @param text - The text to type
+ * @param options - Optional typing options
+ */
+ async type(text: string, options?: { delay?: number }): Promise {
+ await this.element.type(text, options);
+ }
+
+ /**
+ * Types text sequentially with more control over timing
+ * @param text - The text to type
+ * @param options - Optional sequential typing options
+ */
+ async pressSequentially(
+ text: string,
+ options?: { delay?: number },
+ ): Promise {
+ await this.element.pressSequentially(text, options);
+ }
+
+ /**
+ * Gets the current value of the input
+ */
+ async getValue(): Promise {
+ return this.element.inputValue();
+ }
+
+ /**
+ * Clears the input field
+ */
+ async clear(): Promise {
+ await this.element.clear();
+ }
+
+ /**
+ * Checks if the input is visible
+ */
+ async isVisible(): Promise {
+ return this.element.isVisible();
+ }
+
+ /**
+ * Checks if the input is enabled
+ */
+ async isEnabled(): Promise {
+ return this.element.isEnabled();
+ }
+
+ /**
+ * Focuses on the input field
+ */
+ async focus(): Promise {
+ await this.element.focus();
+ }
+}
diff --git a/superset-frontend/playwright/components/core/index.ts b/superset-frontend/playwright/components/core/index.ts
new file mode 100644
index 00000000000..3d99379e99f
--- /dev/null
+++ b/superset-frontend/playwright/components/core/index.ts
@@ -0,0 +1,23 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Core Playwright Components for Superset
+export { Button } from './Button';
+export { Form } from './Form';
+export { Input } from './Input';
diff --git a/superset-frontend/playwright/pages/AuthPage.ts b/superset-frontend/playwright/pages/AuthPage.ts
new file mode 100644
index 00000000000..a925ceaae83
--- /dev/null
+++ b/superset-frontend/playwright/pages/AuthPage.ts
@@ -0,0 +1,122 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Page, Response } from '@playwright/test';
+import { Form } from '../components/core';
+import { URL } from '../utils/urls';
+
+export class AuthPage {
+ private readonly page: Page;
+
+ private readonly loginForm: Form;
+
+ // Selectors specific to the auth/login page
+ private static readonly SELECTORS = {
+ LOGIN_FORM: '[data-test="login-form"]',
+ USERNAME_INPUT: '[data-test="username-input"]',
+ PASSWORD_INPUT: '[data-test="password-input"]',
+ LOGIN_BUTTON: '[data-test="login-button"]',
+ ERROR_SELECTORS: [
+ '[role="alert"]',
+ '.ant-form-item-explain-error',
+ '.ant-form-item-explain.ant-form-item-explain-error',
+ '.alert-danger',
+ ],
+ } as const;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.loginForm = new Form(page, AuthPage.SELECTORS.LOGIN_FORM);
+ }
+
+ /**
+ * Navigate to the login page
+ */
+ async goto(): Promise {
+ await this.page.goto(URL.LOGIN);
+ }
+
+ /**
+ * Wait for login form to be visible
+ */
+ async waitForLoginForm(): Promise {
+ await this.loginForm.waitForVisible({ timeout: 5000 });
+ }
+
+ /**
+ * Login with provided credentials
+ * @param username - Username to enter
+ * @param password - Password to enter
+ */
+ async loginWithCredentials(
+ username: string,
+ password: string,
+ ): Promise {
+ const usernameInput = this.loginForm.getInput(
+ AuthPage.SELECTORS.USERNAME_INPUT,
+ );
+ const passwordInput = this.loginForm.getInput(
+ AuthPage.SELECTORS.PASSWORD_INPUT,
+ );
+ const loginButton = this.loginForm.getButton(
+ AuthPage.SELECTORS.LOGIN_BUTTON,
+ );
+
+ await usernameInput.fill(username);
+ await passwordInput.fill(password);
+ await loginButton.click();
+ }
+
+ /**
+ * Get current page URL
+ */
+ async getCurrentUrl(): Promise {
+ return this.page.url();
+ }
+
+ /**
+ * Get the session cookie specifically
+ */
+ async getSessionCookie(): Promise<{ name: string; value: string } | null> {
+ const cookies = await this.page.context().cookies();
+ return cookies.find((c: any) => c.name === 'session') || null;
+ }
+
+ /**
+ * Check if login form has validation errors
+ */
+ async hasLoginError(): Promise {
+ const visibilityPromises = AuthPage.SELECTORS.ERROR_SELECTORS.map(
+ selector => this.page.locator(selector).isVisible(),
+ );
+ const visibilityResults = await Promise.all(visibilityPromises);
+ return visibilityResults.some((isVisible: any) => isVisible);
+ }
+
+ /**
+ * Wait for a login request to be made and return the response
+ */
+ async waitForLoginRequest(): Promise {
+ return this.page.waitForResponse(
+ (response: any) =>
+ response.url().includes('/login/') &&
+ response.request().method() === 'POST',
+ );
+ }
+}
diff --git a/superset-frontend/playwright/tests/auth/login.spec.ts b/superset-frontend/playwright/tests/auth/login.spec.ts
new file mode 100644
index 00000000000..713cd9c1a76
--- /dev/null
+++ b/superset-frontend/playwright/tests/auth/login.spec.ts
@@ -0,0 +1,88 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { test, expect } from '@playwright/test';
+import { AuthPage } from '../../pages/AuthPage';
+import { URL } from '../../utils/urls';
+
+test.describe('Login view', () => {
+ let authPage: AuthPage;
+
+ test.beforeEach(async ({ page }: any) => {
+ authPage = new AuthPage(page);
+ await authPage.goto();
+ await authPage.waitForLoginForm();
+ });
+
+ test('should redirect to login with incorrect username and password', async ({
+ page,
+ }: any) => {
+ // Setup request interception before login attempt
+ const loginRequestPromise = authPage.waitForLoginRequest();
+
+ // Attempt login with incorrect credentials
+ await authPage.loginWithCredentials('admin', 'wrongpassword');
+
+ // Wait for login request and verify response
+ const loginResponse = await loginRequestPromise;
+ // Failed login returns 401 Unauthorized or 302 redirect to login
+ expect([401, 302]).toContain(loginResponse.status());
+
+ // Wait for redirect to complete before checking URL
+ await page.waitForURL((url: any) => url.pathname.endsWith('login/'), {
+ timeout: 10000,
+ });
+
+ // Verify we stay on login page
+ const currentUrl = await authPage.getCurrentUrl();
+ expect(currentUrl).toContain(URL.LOGIN);
+
+ // Verify error message is shown
+ const hasError = await authPage.hasLoginError();
+ expect(hasError).toBe(true);
+ });
+
+ test('should login with correct username and password', async ({
+ page,
+ }: any) => {
+ // Setup request interception before login attempt
+ const loginRequestPromise = authPage.waitForLoginRequest();
+
+ // Login with correct credentials
+ await authPage.loginWithCredentials('admin', 'general');
+
+ // Wait for login request and verify response
+ const loginResponse = await loginRequestPromise;
+ // Successful login returns 302 redirect
+ expect(loginResponse.status()).toBe(302);
+
+ // Wait for successful redirect to welcome page
+ await page.waitForURL(
+ (url: any) => url.pathname.endsWith('superset/welcome/'),
+ {
+ timeout: 10000,
+ },
+ );
+
+ // Verify specific session cookie exists
+ const sessionCookie = await authPage.getSessionCookie();
+ expect(sessionCookie).not.toBeNull();
+ expect(sessionCookie?.value).toBeTruthy();
+ });
+});
diff --git a/superset-frontend/playwright/utils/urls.ts b/superset-frontend/playwright/utils/urls.ts
new file mode 100644
index 00000000000..67b9e466f35
--- /dev/null
+++ b/superset-frontend/playwright/utils/urls.ts
@@ -0,0 +1,23 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const URL = {
+ LOGIN: 'login/',
+ WELCOME: 'superset/welcome/',
+} as const;