diff --git a/.github/workflows/additional-prod-image-tests.yml b/.github/workflows/additional-prod-image-tests.yml index 69958d1ee2b14..d4e2d4061a8de 100644 --- a/.github/workflows/additional-prod-image-tests.yml +++ b/.github/workflows/additional-prod-image-tests.yml @@ -64,6 +64,10 @@ on: # yamllint disable-line rule:truthy description: "Whether to use uv" required: true type: string + run-ui-e2e-tests: + description: "Whether to run UI e2e tests (true/false)" + required: true + type: string permissions: contents: read jobs: @@ -218,6 +222,42 @@ jobs: use-uv: ${{ inputs.use-uv }} e2e_test_mode: "remote_log" + test-ui-e2e-chromium: + name: "Chromium UI e2e tests with PROD image" + uses: ./.github/workflows/ui-e2e-tests.yml + with: + workflow-name: "Chromium UI e2e tests" + runners: ${{ inputs.runners }} + platform: ${{ inputs.platform }} + default-python-version: "${{ inputs.default-python-version }}" + use-uv: ${{ inputs.use-uv }} + browser: "chromium" + if: inputs.run-ui-e2e-tests == 'true' + + test-ui-e2e-firefox: + name: "Firefox UI e2e tests with PROD image" + uses: ./.github/workflows/ui-e2e-tests.yml + with: + workflow-name: "Firefox UI e2e tests" + runners: ${{ inputs.runners }} + platform: ${{ inputs.platform }} + default-python-version: "${{ inputs.default-python-version }}" + use-uv: ${{ inputs.use-uv }} + browser: "firefox" + if: inputs.run-ui-e2e-tests == 'true' + + test-ui-e2e-webkit: + name: "WebKit UI e2e tests with PROD image" + uses: ./.github/workflows/ui-e2e-tests.yml + with: + workflow-name: "WebKit UI e2e tests" + runners: ${{ inputs.runners }} + platform: ${{ inputs.platform }} + default-python-version: "${{ inputs.default-python-version }}" + use-uv: ${{ inputs.use-uv }} + browser: "webkit" + if: inputs.run-ui-e2e-tests == 'true' + airflow-ctl-integration-tests: timeout-minutes: 60 name: "Airflow CTL integration tests with PROD image" diff --git a/.github/workflows/ci-amd-arm.yml b/.github/workflows/ci-amd-arm.yml index 1ab45e39154bd..ca1954acfdb3e 100644 --- a/.github/workflows/ci-amd-arm.yml +++ b/.github/workflows/ci-amd-arm.yml @@ -118,6 +118,7 @@ jobs: run-task-sdk-integration-tests: ${{ steps.selective-checks.outputs.run-task-sdk-integration-tests }} runner-type: ${{ steps.selective-checks.outputs.runner-type }} run-ui-tests: ${{ steps.selective-checks.outputs.run-ui-tests }} + run-ui-e2e-tests: ${{ steps.selective-checks.outputs.run-ui-e2e-tests }} run-unit-tests: ${{ steps.selective-checks.outputs.run-unit-tests }} run-www-tests: ${{ steps.selective-checks.outputs.run-www-tests }} selected-providers-list-as-string: >- @@ -790,6 +791,7 @@ jobs: run-task-sdk-integration-tests: ${{ needs.build-info.outputs.run-task-sdk-integration-tests }} canary-run: ${{ needs.build-info.outputs.canary-run }} use-uv: ${{ needs.build-info.outputs.use-uv }} + run-ui-e2e-tests: ${{ needs.build-info.outputs.run-ui-e2e-tests }} if: needs.build-info.outputs.prod-image-build == 'true' tests-kubernetes: diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml new file mode 100644 index 0000000000000..802d626658243 --- /dev/null +++ b/.github/workflows/ui-e2e-tests.yml @@ -0,0 +1,150 @@ +# 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. +# +--- + +name: UI End-to-End Tests + +permissions: + contents: read +on: # yamllint disable-line rule:truthy + workflow_dispatch: + inputs: + workflow-name: + description: "Name of the test" + type: string + required: true + runners: + description: "The array of labels (in json form) determining runners." + type: string + default: '["ubuntu-24.04"]' + platform: + description: "Platform for the build - 'linux/amd64' or 'linux/arm64'" + type: string + default: 'linux/amd64' + default-python-version: + description: "Which version of python should be used by default" + type: string + default: '3.10' + use-uv: + description: "Whether to use uv to build the image (true/false)" + type: string + default: 'true' + docker-image-tag: + description: "Tag of the Docker image to test" + type: string + required: true + browser: + description: "Browser to test (chromium, firefox, webkit, all)" + type: string + default: "all" + + workflow_call: + inputs: + workflow-name: + description: "Name of the test" + type: string + required: true + runners: + description: "The array of labels (in json form) determining runners." + required: true + type: string + platform: + description: "Platform for the build - 'linux/amd64' or 'linux/arm64'" + required: true + type: string + default-python-version: + description: "Which version of python should be used by default" + required: true + type: string + use-uv: + description: "Whether to use uv to build the image (true/false)" + required: true + type: string + docker-image-tag: + description: "Tag of the Docker image to test" + type: string + default: "" + browser: + description: "Browser to test (chromium, firefox, webkit, all)" + type: string + default: "all" + +jobs: + test-ui-e2e-tests: + timeout-minutes: 90 + name: ${{ inputs.workflow-name || 'UI E2E Tests' }} + runs-on: ${{ fromJSON(inputs.runners || '["ubuntu-24.04"]') }} + env: + PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version || '3.10' }}" + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_USERNAME: ${{ github.actor }} + VERBOSE: "true" + BROWSER: "${{ inputs.browser || 'all' }}" + PLATFORM: "${{ inputs.platform || 'linux/amd64' }}" + USE_UV: "${{ inputs.use-uv || 'true' }}" + steps: + - name: "Cleanup repo" + shell: bash + run: docker run -v "${GITHUB_WORKSPACE}:/workspace" -u 0:0 bash -c "rm -rf /workspace/*" + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 2 + persist-credentials: false + - name: "Prepare breeze & PROD image: ${{ env.PYTHON_MAJOR_MINOR_VERSION }}" + uses: ./.github/actions/prepare_breeze_and_image + with: + platform: ${{ inputs.platform }} + image-type: "prod" + python: ${{ env.PYTHON_MAJOR_MINOR_VERSION }} + use-uv: ${{ inputs.use-uv }} + make-mnt-writeable-and-cleanup: true + id: breeze + if: github.event_name != 'workflow_dispatch' + - name: "Install Breeze (manual trigger)" + uses: ./.github/actions/breeze + if: github.event_name == 'workflow_dispatch' + - name: "Setup pnpm" + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 + with: + version: 9 + run_install: false + - name: "Setup node" + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 21 + - name: "Install Playwright browsers and dependencies" + run: | + cd airflow-core/src/airflow/ui + pnpm install --frozen-lockfile + pnpm exec playwright install --with-deps + - name: "Test UI e2e tests" + run: breeze testing ui-e2e-tests --browser "$BROWSER" + env: + DOCKER_IMAGE: "${{ inputs.docker-image-tag || '' }}" + - name: "Upload test results" + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: "playwright-report-${{ env.BROWSER }}" + path: | + airflow-core/src/airflow/ui/playwright-report/ + airflow-core/src/airflow/ui/test-results/ + retention-days: 7 + if-no-files-found: 'warn' + if: always() diff --git a/airflow-core/src/airflow/ui/tests/e2e/README.md b/airflow-core/src/airflow/ui/tests/e2e/README.md index 3c805f3b3f4a5..fb5c8895875d7 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/README.md +++ b/airflow-core/src/airflow/ui/tests/e2e/README.md @@ -17,75 +17,138 @@ under the License. --> -# Airflow UI End-to-End Tests +# UI End-to-End Tests -UI automation tests using Playwright for critical Airflow workflows. - -## Prerequisites - -**Requires running Airflow with example DAGs:** - -- Airflow UI running on `http://localhost:28080` (default) -- Admin user: `admin/admin` -- Example DAGs loaded (uses `example_bash_operator`) +End-to-end tests for the Airflow UI using Playwright. ## Running Tests -### Using Breeze +### Using Breeze (Recommended) + +The easiest way to run the tests: ```bash -# Basic run breeze testing ui-e2e-tests -# Specific test with browser visible -breeze testing ui-e2e-tests --test-pattern "dag-trigger.spec.ts" --headed +# Run specific browser +breeze testing ui-e2e-tests --browser firefox + +# Run specific test +breeze testing ui-e2e-tests --test-pattern "dag-trigger.spec.ts" -# Different browsers -breeze testing ui-e2e-tests --browser firefox --headed -breeze testing ui-e2e-tests --browser webkit --headed +# Debug mode +breeze testing ui-e2e-tests --debug-e2e + +# See the browser +breeze testing ui-e2e-tests --headed ``` -### Using pnpm directly +### Direct Execution + +If you already have Airflow running on `http://localhost:8080`: ```bash cd airflow-core/src/airflow/ui - -# Install dependencies pnpm install -pnpm exec playwright install - -# Run tests -pnpm test:e2e:headed # Show browser -pnpm test:e2e:ui # Interactive debugging +pnpm test:e2e:install +pnpm test:e2e ``` -## Test Structure +## CI Integration + +Tests run in GitHub Actions via workflow dispatch. The workflow uses `breeze testing ui-e2e-tests` which handles starting Airflow with docker-compose, running the tests, and cleanup. + +To run manually: + +1. Go to Actions → UI End-to-End Tests +2. Click Run workflow +3. Select browser and other options + +## Directory Structure ``` tests/e2e/ -├── pages/ # Page Object Models +├── pages/ # Page objects +│ ├── BasePage.ts +│ ├── LoginPage.ts +│ └── DagsPage.ts └── specs/ # Test files + └── dag-trigger.spec.ts ``` -## Configuration +## Writing Tests -Set environment variables if needed: +We use the Page Object Model pattern: -```bash -export AIRFLOW_UI_BASE_URL=http://localhost:28080 -export TEST_USERNAME=admin -export TEST_PASSWORD=admin -export TEST_DAG_ID=example_bash_operator +```typescript +// pages/DagPage.ts +export class DagPage extends BasePage { + readonly pauseButton: Locator; + + constructor(page: Page) { + super(page); + this.pauseButton = page.locator('[data-testid="dag-pause"]'); + } + + async pause() { + await this.pauseButton.click(); + } +} + +// specs/dag.spec.ts +test('pause DAG', async ({ page }) => { + const dagPage = new DagPage(page); + await dagPage.goto(); + await dagPage.pause(); + await expect(dagPage.pauseButton).toHaveAttribute('aria-pressed', 'true'); +}); ``` +## Configuration + +Environment variables (with defaults): + +- `AIRFLOW_UI_BASE_URL` - Airflow URL (default: `http://localhost:8080`) +- `TEST_USERNAME` - Username (default: `airflow`) +- `TEST_PASSWORD` - Password (default: `airflow`) +- `TEST_DAG_ID` - Test DAG ID (default: `example_bash_operator`) + ## Debugging -```bash -# Step through tests -breeze testing ui-e2e-tests --debug-e2e +View test report after running locally: -# View test report +```bash pnpm test:e2e:report ``` -Find test artifacts in `test-results/` and reports in `playwright-report/`. +When tests fail in CI, check the uploaded artifacts for screenshots and HTML reports. + +## Breeze Options + +```bash +breeze testing ui-e2e-tests --help +``` + +Common options: + +- `--browser` - chromium, firefox, webkit, or all +- `--headed` - Show browser window +- `--debug-e2e` - Enable Playwright inspector +- `--ui-mode` - Interactive UI mode +- `--test-pattern` - Run specific test file +- `--workers` - Number of parallel workers + +## Test Coverage + +Current tests: + +- Login flow +- DAG triggering +- DAG run status + +Planned tests: + +- DAG pause/unpause +- Task details +- Connections +- Variables diff --git a/dev/breeze/doc/images/output_testing_ui-e2e-tests.svg b/dev/breeze/doc/images/output_testing_ui-e2e-tests.svg index 53b4f84dfc39c..1beb52fbd7229 100644 --- a/dev/breeze/doc/images/output_testing_ui-e2e-tests.svg +++ b/dev/breeze/doc/images/output_testing_ui-e2e-tests.svg @@ -1,4 +1,4 @@ - + - + @@ -126,9 +126,27 @@ + + + + + + + + + + + + + + + + + + - Command: testing ui-e2e-tests + Command: testing ui-e2e-tests @@ -143,29 +161,35 @@ Run UI End-to-End tests using Playwright. -╭─ UI End-to-End test options ─────────────────────────────────────────────────────────────────────────────────────────╮ ---browserBrowser to use for e2e tests(chromium | firefox | webkit | all)[default: all] ---headedRun e2e tests in headed mode (show browser window) ---debug-e2eRun e2e tests in debug mode ---ui-modeRun e2e tests in Playwright UI mode ---test-patternGlob pattern to filter test files(TEXT) ---workersNumber of parallel workers for e2e tests(INTEGER)[default: 1] ---timeoutTest timeout in milliseconds(INTEGER)[default: 60000] ---reporterTest reporter for e2e tests(list | dot | line | json | junit | html | github)[default: html] -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Test environment for UI tests ──────────────────────────────────────────────────────────────────────────────────────╮ ---airflow-ui-base-urlBase URL for Airflow UI during e2e tests(TEXT)[default: http://localhost:28080] ---test-admin-usernameAdmin username for e2e tests(TEXT)[default: admin] ---test-admin-passwordAdmin password for e2e tests(TEXT)[default: admin] -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Advanced flags for UI e2e tests ────────────────────────────────────────────────────────────────────────────────────╮ ---force-reinstall-depsForce reinstall UI dependencies -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ ---dry-run-DIf dry-run is set, commands are only printed, not executed. ---verbose-vPrint verbose information about performed steps. ---help-hShow this message and exit. -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Docker image options ───────────────────────────────────────────────────────────────────────────────────────────────╮ +--python-pPython major/minor version used in Airflow image for images.(>3.10< | 3.11 | 3.12 | 3.13) +[default: 3.10]                                              +--image-name-nName of the image to verify (overrides --python).(TEXT) +--github-repository-gGitHub repository used to pull, push run images.(TEXT)[default: apache/airflow] +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ UI End-to-End test options ─────────────────────────────────────────────────────────────────────────────────────────╮ +--browserBrowser to use for e2e tests(chromium | firefox | webkit | all)[default: all] +--headedRun e2e tests in headed mode (show browser window) +--debug-e2eRun e2e tests in debug mode +--ui-modeRun e2e tests in Playwright UI mode +--test-patternGlob pattern to filter test files(TEXT) +--workersNumber of parallel workers for e2e tests(INTEGER)[default: 1] +--timeoutTest timeout in milliseconds(INTEGER)[default: 60000] +--reporterTest reporter for e2e tests(list | dot | line | json | junit | html | github)[default: html] +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Test environment for UI tests ──────────────────────────────────────────────────────────────────────────────────────╮ +--airflow-ui-base-urlBase URL for Airflow UI during e2e tests(TEXT)[default: http://localhost:8080] +--test-admin-usernameAdmin username for e2e tests(TEXT)[default: airflow] +--test-admin-passwordAdmin password for e2e tests(TEXT)[default: airflow] +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Advanced flags for UI e2e tests ────────────────────────────────────────────────────────────────────────────────────╮ +--force-reinstall-depsForce reinstall UI dependencies +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +--dry-run-DIf dry-run is set, commands are only printed, not executed. +--verbose-vPrint verbose information about performed steps. +--help-hShow this message and exit. +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/dev/breeze/doc/images/output_testing_ui-e2e-tests.txt b/dev/breeze/doc/images/output_testing_ui-e2e-tests.txt index 475047eb78e98..713d531dc288b 100644 --- a/dev/breeze/doc/images/output_testing_ui-e2e-tests.txt +++ b/dev/breeze/doc/images/output_testing_ui-e2e-tests.txt @@ -1 +1 @@ -37da219fd2514ea3a6027056c903360c +d64fae90ee8e43f6f76c8e58efbb706e diff --git a/dev/breeze/src/airflow_breeze/commands/common_options.py b/dev/breeze/src/airflow_breeze/commands/common_options.py index 9e058176598a4..4d8f3fc97c636 100644 --- a/dev/breeze/src/airflow_breeze/commands/common_options.py +++ b/dev/breeze/src/airflow_breeze/commands/common_options.py @@ -572,7 +572,7 @@ def _normalize_platform(ctx: click.core.Context, param: click.core.Option, value option_airflow_ui_base_url = click.option( "--airflow-ui-base-url", help="Base URL for Airflow UI during e2e tests", - default="http://localhost:28080", + default="http://localhost:8080", show_default=True, envvar="AIRFLOW_UI_BASE_URL", ) @@ -642,7 +642,7 @@ def _normalize_platform(ctx: click.core.Context, param: click.core.Option, value option_test_admin_username = click.option( "--test-admin-username", help="Admin username for e2e tests", - default="admin", + default="airflow", show_default=True, envvar="TEST_ADMIN_USERNAME", ) @@ -650,7 +650,7 @@ def _normalize_platform(ctx: click.core.Context, param: click.core.Option, value option_test_admin_password = click.option( "--test-admin-password", help="Admin password for e2e tests", - default="admin", + default="airflow", show_default=True, envvar="TEST_ADMIN_PASSWORD", ) diff --git a/dev/breeze/src/airflow_breeze/commands/testing_commands.py b/dev/breeze/src/airflow_breeze/commands/testing_commands.py index 0dce9cf466aeb..e0feb90dad314 100644 --- a/dev/breeze/src/airflow_breeze/commands/testing_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/testing_commands.py @@ -1435,6 +1435,9 @@ def airflow_e2e_tests( allow_extra_args=True, ), ) +@option_python +@option_image_name +@option_github_repository @option_airflow_ui_base_url @option_browser @option_debug_e2e @@ -1451,6 +1454,9 @@ def airflow_e2e_tests( @option_verbose @click.argument("extra_playwright_args", nargs=-1, type=click.Path(path_type=str)) def ui_e2e_tests( + python: str, + image_name: str | None, + github_repository: str, airflow_ui_base_url: str, browser: str, debug_e2e: bool, @@ -1466,76 +1472,118 @@ def ui_e2e_tests( extra_playwright_args: tuple, ): """Run UI end-to-end tests using Playwright.""" + import shutil import sys + import tempfile from pathlib import Path + from airflow_breeze.params.build_prod_params import BuildProdParams from airflow_breeze.utils.console import get_console from airflow_breeze.utils.run_utils import check_pnpm_installed, run_command from airflow_breeze.utils.shared_options import get_dry_run, get_verbose perform_environment_checks() - check_pnpm_installed() airflow_root = Path(__file__).resolve().parents[5] ui_dir = airflow_root / "airflow-core" / "src" / "airflow" / "ui" + docker_compose_source = ( + airflow_root / "airflow-core" / "docs" / "howto" / "docker-compose" / "docker-compose.yaml" + ) if not ui_dir.exists(): get_console().print(f"[error]UI directory not found: {ui_dir}[/]") sys.exit(1) - env_vars = { - "AIRFLOW_UI_BASE_URL": airflow_ui_base_url, - "TEST_USERNAME": test_admin_username, - "TEST_PASSWORD": test_admin_password, - "TEST_DAG_ID": "example_bash_operator", - } + tmp_dir = Path(tempfile.mkdtemp(prefix="airflow-ui-e2e-")) + get_console().print(f"[info]Using temporary directory: {tmp_dir}[/]") + + try: + from airflow_breeze.utils.docker_compose_utils import ( + ensure_image_exists_and_build_if_needed, + setup_airflow_docker_compose_environment, + start_docker_compose_and_wait_for_health, + stop_docker_compose, + ) + + if image_name is None: + image_name = os.environ.get("DOCKER_IMAGE") + if image_name is None or image_name.strip() == "": + build_params = BuildProdParams(python=python, github_repository=github_repository) + image_name = build_params.airflow_image_name + + get_console().print(f"[info]Running UI E2E tests with PROD image: {image_name}[/]") + ensure_image_exists_and_build_if_needed(image_name, python) + + env_vars = { + "AIRFLOW_UID": str(os.getuid()), + "AIRFLOW__CORE__LOAD_EXAMPLES": "true", + "AIRFLOW_IMAGE_NAME": image_name, + } + + tmp_dir, dot_env = setup_airflow_docker_compose_environment( + docker_compose_source=docker_compose_source, + tmp_dir=tmp_dir, + env_vars=env_vars, + ) + + result = start_docker_compose_and_wait_for_health(tmp_dir, airflow_base_url=airflow_ui_base_url) + if result != 0: + sys.exit(result) + + get_console().print("[success]Airflow is ready! Login with default credentials: airflow/airflow[/]") + + env_vars = { + "AIRFLOW_UI_BASE_URL": airflow_ui_base_url, + "TEST_USERNAME": test_admin_username, + "TEST_PASSWORD": test_admin_password, + "TEST_DAG_ID": "example_bash_operator", + } + + if force_reinstall_deps: + clean_cmd = ["pnpm", "install", "--force"] + if not get_dry_run(): + run_command(clean_cmd, cwd=ui_dir, env=env_vars, verbose_override=get_verbose()) + else: + install_cmd = ["pnpm", "install"] + if not get_dry_run(): + run_command(install_cmd, cwd=ui_dir, env=env_vars, verbose_override=get_verbose()) + + install_browsers_cmd = ["pnpm", "exec", "playwright", "install"] + if browser != "all": + install_browsers_cmd.append(browser) - if force_reinstall_deps: - clean_cmd = ["pnpm", "install", "--force"] - if not get_dry_run(): - run_command(clean_cmd, cwd=ui_dir, env=env_vars, verbose_override=get_verbose()) - else: - install_cmd = ["pnpm", "install"] if not get_dry_run(): - run_command(install_cmd, cwd=ui_dir, env=env_vars, verbose_override=get_verbose()) - - install_browsers_cmd = ["pnpm", "exec", "playwright", "install"] - if browser != "all": - install_browsers_cmd.append(browser) - - if not get_dry_run(): - run_command(install_browsers_cmd, cwd=ui_dir, env=env_vars, verbose_override=get_verbose()) - - get_console().print(f"[info]Using Airflow at: {airflow_ui_base_url}[/]") - - playwright_cmd = ["pnpm", "exec", "playwright", "test"] - - if browser != "all": - playwright_cmd.extend(["--project", browser]) - if headed: - playwright_cmd.append("--headed") - if debug_e2e: - playwright_cmd.append("--debug") - if ui_mode: - playwright_cmd.append("--ui") - if workers > 1: - playwright_cmd.extend(["--workers", str(workers)]) - if timeout != 60000: - playwright_cmd.extend(["--timeout", str(timeout)]) - if reporter != "html": - playwright_cmd.extend(["--reporter", reporter]) - if test_pattern: - playwright_cmd.append(test_pattern) - if extra_playwright_args: - playwright_cmd.extend(extra_playwright_args) - - get_console().print(f"[info]Running: {' '.join(playwright_cmd)}[/]") - - if get_dry_run(): - return + run_command(install_browsers_cmd, cwd=ui_dir, env=env_vars, verbose_override=get_verbose()) + + get_console().print(f"[info]Using Airflow at: {airflow_ui_base_url}[/]") + + playwright_cmd = ["pnpm", "exec", "playwright", "test"] + + if browser != "all": + playwright_cmd.extend(["--project", browser]) + if headed: + playwright_cmd.append("--headed") + if debug_e2e: + playwright_cmd.append("--debug") + if ui_mode: + playwright_cmd.append("--ui") + if workers > 1: + playwright_cmd.extend(["--workers", str(workers)]) + if timeout != 60000: + playwright_cmd.extend(["--timeout", str(timeout)]) + if reporter != "html": + playwright_cmd.extend(["--reporter", reporter]) + if test_pattern: + playwright_cmd.append(test_pattern) + if extra_playwright_args: + playwright_cmd.extend(extra_playwright_args) + + get_console().print(f"[info]Running: {' '.join(playwright_cmd)}[/]") + + if get_dry_run(): + return - try: result = run_command( playwright_cmd, cwd=ui_dir, env=env_vars, verbose_override=get_verbose(), check=False ) @@ -1544,11 +1592,16 @@ def ui_e2e_tests( if report_path.exists(): get_console().print(f"[info]Report: file://{report_path}[/]") + stop_docker_compose(tmp_dir) + shutil.rmtree(tmp_dir, ignore_errors=True) + if result.returncode != 0: sys.exit(result.returncode) except Exception as e: get_console().print(f"[error]{str(e)}[/]") + stop_docker_compose(tmp_dir) + shutil.rmtree(tmp_dir, ignore_errors=True) sys.exit(1) diff --git a/dev/breeze/src/airflow_breeze/commands/testing_commands_config.py b/dev/breeze/src/airflow_breeze/commands/testing_commands_config.py index 78f327bd45b52..68a7150f7f31d 100644 --- a/dev/breeze/src/airflow_breeze/commands/testing_commands_config.py +++ b/dev/breeze/src/airflow_breeze/commands/testing_commands_config.py @@ -315,6 +315,14 @@ } ], "breeze testing ui-e2e-tests": [ + { + "name": "Docker image options", + "options": [ + "--python", + "--image-name", + "--github-repository", + ], + }, { "name": "UI End-to-End test options", "options": [ diff --git a/dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py b/dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py new file mode 100644 index 0000000000000..dfd39859425c3 --- /dev/null +++ b/dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py @@ -0,0 +1,165 @@ +# 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. +"""Utilities for managing Airflow docker-compose environments in tests.""" + +from __future__ import annotations + +import os +import sys +import tempfile +import time +import urllib.error +import urllib.request +from collections.abc import Callable +from pathlib import Path +from shutil import copyfile + +import yaml +from cryptography.fernet import Fernet + +from airflow_breeze.utils.console import get_console +from airflow_breeze.utils.run_utils import run_command + + +def setup_airflow_docker_compose_environment( + docker_compose_source: Path, + tmp_dir: Path | None = None, + env_vars: dict[str, str] | None = None, + docker_compose_modifications: Callable[[dict, Path], dict] | None = None, +) -> tuple[Path, Path]: + """Set up a temporary directory with docker-compose files for Airflow.""" + if tmp_dir is None: + tmp_dir = Path(tempfile.mkdtemp(prefix="airflow-docker-compose-")) + + docker_compose_path = tmp_dir / "docker-compose.yaml" + copyfile(docker_compose_source, docker_compose_path) + + for subdir in ("dags", "logs", "plugins", "config"): + (tmp_dir / subdir).mkdir(exist_ok=True) + + env_vars = env_vars or {} + + if "FERNET_KEY" not in env_vars: + env_vars["FERNET_KEY"] = Fernet.generate_key().decode() + + if "AIRFLOW_UID" not in env_vars: + env_vars["AIRFLOW_UID"] = str(os.getuid()) + + dot_env_file = tmp_dir / ".env" + env_content = "\n".join([f"{key}={value}" for key, value in env_vars.items()]) + dot_env_file.write_text(env_content + "\n") + + if docker_compose_modifications: + with open(docker_compose_path) as f: + compose_config = yaml.safe_load(f) + compose_config = docker_compose_modifications(compose_config, tmp_dir) + with open(docker_compose_path, "w") as f: + yaml.dump(compose_config, f, default_flow_style=False) + + return tmp_dir, dot_env_file + + +def start_docker_compose_and_wait_for_health( + tmp_dir: Path, + airflow_base_url: str = "http://localhost:8080", + max_wait: int = 180, + check_interval: int = 5, +) -> int: + """Start docker-compose and wait for Airflow to be healthy.""" + health_check_url = f"{airflow_base_url}/api/v2/monitor/health" + + get_console().print("[info]Starting Airflow services with docker-compose...[/]") + compose_up_result = run_command( + ["docker", "compose", "up", "-d"], cwd=tmp_dir, check=False, verbose_override=True + ) + if compose_up_result.returncode != 0: + get_console().print("[error]Failed to start docker-compose[/]") + return compose_up_result.returncode + + get_console().print(f"[info]Waiting for Airflow at {health_check_url}...[/]") + elapsed = 0 + while elapsed < max_wait: + try: + response = urllib.request.urlopen(health_check_url, timeout=5) + if response.status == 200: + get_console().print("[success]Airflow is ready![/]") + return 0 + except (urllib.error.URLError, urllib.error.HTTPError, Exception): + time.sleep(check_interval) + elapsed += check_interval + if elapsed % 15 == 0: + get_console().print(f"[info]Still waiting... ({elapsed}s/{max_wait}s)[/]") + + get_console().print(f"[error]Airflow did not become ready within {max_wait} seconds[/]") + get_console().print("[info]Docker compose logs:[/]") + run_command(["docker", "compose", "logs"], cwd=tmp_dir, check=False) + return 1 + + +def stop_docker_compose(tmp_dir: Path, remove_volumes: bool = True) -> None: + """Stop and cleanup docker-compose services.""" + get_console().print("[info]Stopping docker-compose services...[/]") + cmd = ["docker", "compose", "down"] + if remove_volumes: + cmd.append("-v") + run_command(cmd, cwd=tmp_dir, check=False) + get_console().print("[success]Docker-compose cleaned up.[/]") + + +def ensure_image_exists_and_build_if_needed(image_name: str, python: str) -> None: + inspect_result = run_command( + ["docker", "inspect", image_name], check=False, capture_output=True, text=True + ) + if inspect_result.returncode != 0: + get_console().print(f"[error]Error when inspecting PROD image: {inspect_result.returncode}[/]") + get_console().print(inspect_result.stderr or "", highlight=False) + if "no such object" in inspect_result.stderr.lower(): + get_console().print( + f"The image {image_name} does not exist locally. " + f"Building it now with: breeze prod-image build --python {python}" + ) + build_result = run_command(["breeze", "prod-image", "build", "--python", python], check=False) + if build_result.returncode != 0: + get_console().print("[error]Failed to build image[/]") + sys.exit(1) + get_console().print(f"[info]Tagging the built image as {image_name}[/]") + list_images_result = run_command( + [ + "docker", + "images", + "--format", + "{{.Repository}}:{{.Tag}}", + "--filter", + "reference=*/airflow:latest", + ], + check=False, + capture_output=True, + text=True, + ) + if list_images_result.returncode == 0 and list_images_result.stdout.strip(): + built_image = list_images_result.stdout.strip().split("\n")[0] + get_console().print(f"[info]Found built image: {built_image}[/]") + tag_result = run_command(["docker", "tag", built_image, image_name], check=False) + if tag_result.returncode != 0: + get_console().print(f"[error]Failed to tag image {built_image} as {image_name}[/]") + sys.exit(1) + get_console().print(f"[success]Successfully tagged {built_image} as {image_name}[/]") + else: + get_console().print("[warning]Could not find built image to tag. Docker compose may fail.[/]") + else: + get_console().print(f"[error]Failed to inspect image {image_name}[/]") + sys.exit(1) diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py b/dev/breeze/src/airflow_breeze/utils/selective_checks.py index a23ac0c919aa9..e7ed94e27c985 100644 --- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py +++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py @@ -868,6 +868,10 @@ def run_api_codegen(self) -> bool: def run_ui_tests(self) -> bool: return self._should_be_run(FileGroupForCi.UI_FILES) + @cached_property + def run_ui_e2e_tests(self) -> bool: + return self._should_be_run(FileGroupForCi.UI_FILES) + @cached_property def run_amazon_tests(self) -> bool: if self.providers_test_types_list_as_strings_in_json == "[]":