From da212558c5661aeec899ea63fe6ff9516d64caf4 Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Tue, 28 Oct 2025 21:38:04 +0530 Subject: [PATCH 1/8] Revert "Fix memory leak in remote logging connection cache (#56695)" This reverts commit 416c73e864b5c9a52b50053baa7876bcb5bcfe38. --- .../airflow/sdk/execution_time/supervisor.py | 75 +++++++------------ .../execution_time/test_supervisor.py | 41 ---------- 2 files changed, 29 insertions(+), 87 deletions(-) diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py b/task-sdk/src/airflow/sdk/execution_time/supervisor.py index 9675887cb8a3e..53d63f4950055 100644 --- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py +++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py @@ -33,6 +33,7 @@ from collections.abc import Callable, Generator from contextlib import contextmanager, suppress from datetime import datetime, timezone +from functools import lru_cache from http import HTTPStatus from socket import socket, socketpair from typing import ( @@ -826,10 +827,8 @@ def _check_subprocess_exit( return self._exit_code -_REMOTE_LOGGING_CONN_CACHE: dict[str, Connection | None] = {} - - -def _fetch_remote_logging_conn(conn_id: str, client: Client) -> Connection | None: +@lru_cache +def _get_remote_logging_conn(conn_id: str, client: Client) -> Connection | None: """ Fetch and cache connection for remote logging. @@ -838,22 +837,18 @@ def _fetch_remote_logging_conn(conn_id: str, client: Client) -> Connection | Non client: API client for making requests Returns: - Connection object or None if not found. + Connection object or None if not found """ # Since we need to use the API Client directly, we can't use Connection.get as that would try to use # SUPERVISOR_COMMS # TODO: Store in the SecretsCache if its enabled - see #48858 - if conn_id in _REMOTE_LOGGING_CONN_CACHE: - return _REMOTE_LOGGING_CONN_CACHE[conn_id] - backends = ensure_secrets_backend_loaded() for secrets_backend in backends: try: conn = secrets_backend.get_connection(conn_id=conn_id) if conn: - _REMOTE_LOGGING_CONN_CACHE[conn_id] = conn return conn except Exception: log.exception( @@ -867,12 +862,8 @@ def _fetch_remote_logging_conn(conn_id: str, client: Client) -> Connection | Non conn_result = ConnectionResult.from_conn_response(conn) from airflow.sdk.definitions.connection import Connection - result: Connection | None = Connection(**conn_result.model_dump(exclude={"type"}, by_alias=True)) - else: - result = None - - _REMOTE_LOGGING_CONN_CACHE[conn_id] = result - return result + return Connection(**conn_result.model_dump(exclude={"type"}, by_alias=True)) + return None @contextlib.contextmanager @@ -887,8 +878,7 @@ def _remote_logging_conn(client: Client): This is needed as the BaseHook.get_connection looks for SUPERVISOR_COMMS, but we are still in the supervisor process when this is needed, so that doesn't exist yet. - The connection details are fetched eagerly on every invocation to avoid retaining - per-task API client instances in global caches. + This function uses @lru_cache for connection caching to avoid repeated API calls. """ from airflow.sdk.log import load_remote_conn_id, load_remote_log_handler @@ -897,8 +887,8 @@ def _remote_logging_conn(client: Client): yield return - # Fetch connection details on-demand without caching the entire API client instance - conn = _fetch_remote_logging_conn(conn_id, client) + # Use cached connection fetcher + conn = _get_remote_logging_conn(conn_id, client) if conn: key = f"AIRFLOW_CONN_{conn_id.upper()}" @@ -1922,11 +1912,9 @@ def supervise( if not dag_rel_path: raise ValueError("dag_path is required") - close_client = False if not client: limits = httpx.Limits(max_keepalive_connections=1, max_connections=10) client = Client(base_url=server or "", limits=limits, dry_run=dry_run, token=token) - close_client = True start = time.monotonic() @@ -1945,29 +1933,24 @@ def supervise( reset_secrets_masker() - try: - process = ActivitySubprocess.start( - dag_rel_path=dag_rel_path, - what=ti, - client=client, - logger=logger, - bundle_info=bundle_info, - subprocess_logs_to_stdout=subprocess_logs_to_stdout, - ) + process = ActivitySubprocess.start( + dag_rel_path=dag_rel_path, + what=ti, + client=client, + logger=logger, + bundle_info=bundle_info, + subprocess_logs_to_stdout=subprocess_logs_to_stdout, + ) - exit_code = process.wait() - end = time.monotonic() - log.info( - "Task finished", - task_instance_id=str(ti.id), - exit_code=exit_code, - duration=end - start, - final_state=process.final_state, - ) - return exit_code - finally: - if log_path and log_file_descriptor: - log_file_descriptor.close() - if close_client and client: - with suppress(Exception): - client.close() + exit_code = process.wait() + end = time.monotonic() + log.info( + "Task finished", + task_instance_id=str(ti.id), + exit_code=exit_code, + duration=end - start, + final_state=process.final_state, + ) + if log_path and log_file_descriptor: + log_file_descriptor.close() + return exit_code diff --git a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py index 7cc057ccdd454..837968fe09759 100644 --- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py +++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py @@ -2630,47 +2630,6 @@ def mock_upload_to_remote(process_log, ti): assert connection_available["conn_uri"] is not None, "Connection URI was None during upload" -def test_remote_logging_conn_caches_connection_not_client(monkeypatch): - """Test that connection caching doesn't retain API client references.""" - import gc - import weakref - - from airflow.sdk import log as sdk_log - from airflow.sdk.execution_time import supervisor - - class ExampleBackend: - def __init__(self): - self.calls = 0 - - def get_connection(self, conn_id: str): - self.calls += 1 - from airflow.sdk.definitions.connection import Connection - - return Connection(conn_id=conn_id, conn_type="example") - - backend = ExampleBackend() - monkeypatch.setattr(supervisor, "ensure_secrets_backend_loaded", lambda: [backend]) - monkeypatch.setattr(sdk_log, "load_remote_log_handler", lambda: object()) - monkeypatch.setattr(sdk_log, "load_remote_conn_id", lambda: "test_conn") - monkeypatch.delenv("AIRFLOW_CONN_TEST_CONN", raising=False) - - def noop_request(request: httpx.Request) -> httpx.Response: - return httpx.Response(200) - - clients = [] - for _ in range(3): - client = make_client(transport=httpx.MockTransport(noop_request)) - clients.append(weakref.ref(client)) - with _remote_logging_conn(client): - pass - client.close() - del client - - gc.collect() - assert backend.calls == 1, "Connection should be cached, not fetched multiple times" - assert all(ref() is None for ref in clients), "Client instances should be garbage collected" - - def test_process_log_messages_from_subprocess(monkeypatch, caplog): from airflow.sdk._shared.logging.structlog import PER_LOGGER_LEVELS From d6ef5cbef71c6cc997d02a4aac6fdf2a743ba6f9 Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Mon, 1 Dec 2025 20:36:50 +0530 Subject: [PATCH 2/8] initiate airflow instance + ci workflow --- .github/workflows/ui-e2e-tests.yml | 130 ++++++++++++++ .../src/airflow/ui/tests/e2e/README.md | 141 ++++++++++----- .../images/output_testing_ui-e2e-tests.svg | 80 ++++++--- .../images/output_testing_ui-e2e-tests.txt | 2 +- .../airflow_breeze/commands/common_options.py | 6 +- .../commands/testing_commands.py | 153 ++++++++++------ .../commands/testing_commands_config.py | 8 + .../utils/docker_compose_utils.py | 164 ++++++++++++++++++ 8 files changed, 563 insertions(+), 121 deletions(-) create mode 100644 .github/workflows/ui-e2e-tests.yml create mode 100644 dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml new file mode 100644 index 0000000000000..1597c7758a6d2 --- /dev/null +++ b/.github/workflows/ui-e2e-tests.yml @@ -0,0 +1,130 @@ +# 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: 60 + name: ${{ inputs.workflow-name }} + runs-on: ${{ fromJSON(inputs.runners) }} + env: + PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}" + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_USERNAME: ${{ github.actor }} + VERBOSE: "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@v4 + 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 + - name: "Test UI e2e tests" + run: breeze testing ui-e2e-tests --browser "$BROWSER" + env: + BROWSER: "${{ inputs.browser }}" + DOCKER_IMAGE: "${{ inputs.docker-image-tag }}" + - name: "Upload test results" + uses: actions/upload-artifact@v4 + with: + name: "playwright-report-${{ inputs.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..27d9c014dc2ca --- /dev/null +++ b/dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py @@ -0,0 +1,164 @@ +# 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 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 | 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) From fbe667e6e5ea1e9b328919d75e706fab3e700f5b Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Mon, 1 Dec 2025 20:43:37 +0530 Subject: [PATCH 3/8] Remove temporary push trigger --- .github/workflows/ui-e2e-tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml index 1597c7758a6d2..2cf03d2523cbf 100644 --- a/.github/workflows/ui-e2e-tests.yml +++ b/.github/workflows/ui-e2e-tests.yml @@ -22,6 +22,9 @@ name: UI End-to-End Tests permissions: contents: read on: # yamllint disable-line rule:truthy + push: + branches: + - 'ci-workflow-ui-e2e' workflow_dispatch: inputs: workflow-name: From 2d9271a41cafd7142fbf2d8a1e41dadd15d31ab9 Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Mon, 1 Dec 2025 20:59:53 +0530 Subject: [PATCH 4/8] remove on push --- .github/workflows/ui-e2e-tests.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml index 2cf03d2523cbf..99cac7a155806 100644 --- a/.github/workflows/ui-e2e-tests.yml +++ b/.github/workflows/ui-e2e-tests.yml @@ -22,9 +22,6 @@ name: UI End-to-End Tests permissions: contents: read on: # yamllint disable-line rule:truthy - push: - branches: - - 'ci-workflow-ui-e2e' workflow_dispatch: inputs: workflow-name: @@ -103,7 +100,7 @@ jobs: 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@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 persist-credentials: false @@ -122,7 +119,7 @@ jobs: BROWSER: "${{ inputs.browser }}" DOCKER_IMAGE: "${{ inputs.docker-image-tag }}" - name: "Upload test results" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: "playwright-report-${{ inputs.browser }}" path: | From 082d293e105598d72c182256fc28773ad2af47ea Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Mon, 1 Dec 2025 21:26:20 +0530 Subject: [PATCH 5/8] fix mypy --- dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py b/dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py index 27d9c014dc2ca..dfd39859425c3 100644 --- a/dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py +++ b/dev/breeze/src/airflow_breeze/utils/docker_compose_utils.py @@ -24,6 +24,7 @@ import time import urllib.error import urllib.request +from collections.abc import Callable from pathlib import Path from shutil import copyfile @@ -38,7 +39,7 @@ 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 | 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: From 339435ab19027ceeffe35120c86750ca9990fb35 Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Tue, 9 Dec 2025 11:20:19 +0530 Subject: [PATCH 6/8] run workflow onevery PR --- .github/workflows/ui-e2e-tests.yml | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml index 99cac7a155806..bca936ab018ab 100644 --- a/.github/workflows/ui-e2e-tests.yml +++ b/.github/workflows/ui-e2e-tests.yml @@ -22,6 +22,14 @@ name: UI End-to-End Tests permissions: contents: read on: # yamllint disable-line rule:truthy + pull_request: + branches: + - main + - v[0-9]+-[0-9]+-test + - v[0-9]+-[0-9]+-stable + paths: + - 'airflow-core/src/airflow/ui/**' + - '.github/workflows/ui-e2e-tests.yml' workflow_dispatch: inputs: workflow-name: @@ -87,14 +95,17 @@ on: # yamllint disable-line rule:truthy jobs: test-ui-e2e-tests: timeout-minutes: 60 - name: ${{ inputs.workflow-name }} - runs-on: ${{ fromJSON(inputs.runners) }} + name: ${{ inputs.workflow-name || 'UI E2E Tests' }} + runs-on: ${{ fromJSON(inputs.runners || '["ubuntu-24.04"]') }} env: - PYTHON_MAJOR_MINOR_VERSION: "${{ inputs.default-python-version }}" + 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 @@ -107,21 +118,20 @@ jobs: - name: "Prepare breeze & PROD image: ${{ env.PYTHON_MAJOR_MINOR_VERSION }}" uses: ./.github/actions/prepare_breeze_and_image with: - platform: ${{ inputs.platform }} + platform: ${{ env.PLATFORM }} image-type: "prod" python: ${{ env.PYTHON_MAJOR_MINOR_VERSION }} - use-uv: ${{ inputs.use-uv }} + use-uv: ${{ env.USE_UV }} make-mnt-writeable-and-cleanup: true id: breeze - name: "Test UI e2e tests" run: breeze testing ui-e2e-tests --browser "$BROWSER" env: - BROWSER: "${{ inputs.browser }}" - DOCKER_IMAGE: "${{ inputs.docker-image-tag }}" + DOCKER_IMAGE: "${{ inputs.docker-image-tag || '' }}" - name: "Upload test results" uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: "playwright-report-${{ inputs.browser }}" + name: "playwright-report-${{ env.BROWSER }}" path: | airflow-core/src/airflow/ui/playwright-report/ airflow-core/src/airflow/ui/test-results/ From 2000be6ac0b445e8e771f9ebe60c4bf246a9cf17 Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Tue, 9 Dec 2025 18:14:01 +0530 Subject: [PATCH 7/8] update workflow to run from every PR using selective check --- .../workflows/additional-prod-image-tests.yml | 15 +++++ .github/workflows/ci-amd-arm.yml | 2 + .github/workflows/ui-e2e-tests.yml | 55 +++++-------------- .../airflow_breeze/utils/selective_checks.py | 4 ++ 4 files changed, 36 insertions(+), 40 deletions(-) diff --git a/.github/workflows/additional-prod-image-tests.yml b/.github/workflows/additional-prod-image-tests.yml index 69958d1ee2b14..6f8912eb6ee0e 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,17 @@ jobs: use-uv: ${{ inputs.use-uv }} e2e_test_mode: "remote_log" + test-ui-e2e-tests: + name: "UI e2e tests with PROD image" + uses: ./.github/workflows/ui-e2e-tests.yml + with: + workflow-name: "UI e2e tests" + runners: ${{ inputs.runners }} + platform: ${{ inputs.platform }} + default-python-version: "${{ inputs.default-python-version }}" + use-uv: ${{ inputs.use-uv }} + 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 index 0db2ec63607c3..802d626658243 100644 --- a/.github/workflows/ui-e2e-tests.yml +++ b/.github/workflows/ui-e2e-tests.yml @@ -22,14 +22,6 @@ name: UI End-to-End Tests permissions: contents: read on: # yamllint disable-line rule:truthy - pull_request: - branches: - - main - - v[0-9]+-[0-9]+-test - - v[0-9]+-[0-9]+-stable - paths: - - 'airflow-core/src/airflow/ui/**' - - '.github/workflows/ui-e2e-tests.yml' workflow_dispatch: inputs: workflow-name: @@ -92,10 +84,6 @@ on: # yamllint disable-line rule:truthy type: string default: "all" -concurrency: - group: ui-e2e-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - jobs: test-ui-e2e-tests: timeout-minutes: 90 @@ -119,6 +107,19 @@ jobs: 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: @@ -128,37 +129,11 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 21 - - name: "Compile UI assets" + - name: "Install Playwright browsers and dependencies" run: | cd airflow-core/src/airflow/ui pnpm install --frozen-lockfile - pnpm build - cd ../api_fastapi/auth/managers/simple/ui - pnpm install --frozen-lockfile - pnpm build - - name: "Install WebKit dependencies" - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libgtk-4-1 \ - libgraphene-1.0-0 \ - libwoff1 \ - libvpx9 \ - libevent-2.1-7 \ - libopus0 \ - libgstreamer-plugins-base1.0-0 \ - gstreamer1.0-plugins-good \ - gstreamer1.0-plugins-bad \ - libavif16 \ - libharfbuzz-icu0 \ - libsecret-1-0 \ - libhyphen0 \ - libmanette-0.2-0 \ - libgles2 \ - libx264-164 \ - flite1-dev - - name: "Install Breeze" - uses: ./.github/actions/breeze + pnpm exec playwright install --with-deps - name: "Test UI e2e tests" run: breeze testing ui-e2e-tests --browser "$BROWSER" env: 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 == "[]": From 46877c9f072e3aae1c460e6087274cf72577eb11 Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Tue, 9 Dec 2025 21:07:21 +0530 Subject: [PATCH 8/8] add sperate UI jobs per browser --- .../workflows/additional-prod-image-tests.yml | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/additional-prod-image-tests.yml b/.github/workflows/additional-prod-image-tests.yml index 6f8912eb6ee0e..d4e2d4061a8de 100644 --- a/.github/workflows/additional-prod-image-tests.yml +++ b/.github/workflows/additional-prod-image-tests.yml @@ -222,15 +222,40 @@ jobs: use-uv: ${{ inputs.use-uv }} e2e_test_mode: "remote_log" - test-ui-e2e-tests: - name: "UI e2e tests with PROD image" + test-ui-e2e-chromium: + name: "Chromium UI e2e tests with PROD image" uses: ./.github/workflows/ui-e2e-tests.yml with: - workflow-name: "UI e2e tests" + 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: