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;