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 c11c2dd353c3dfaef99beecf20fd0f8a4b14d377 Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Wed, 19 Nov 2025 21:19:09 +0530 Subject: [PATCH 2/8] adding playwright tests --- airflow-core/src/airflow/ui/package.json | 11 +- airflow-core/src/airflow/ui/pnpm-lock.yaml | 38 +++ .../src/airflow/ui/tests/e2e/README.md | 219 ++++++++++++++++++ .../airflow/ui/tests/e2e/pages/BasePage.ts | 59 +++++ .../airflow/ui/tests/e2e/pages/DagsPage.ts | 165 +++++++++++++ .../airflow/ui/tests/e2e/pages/LoginPage.ts | 79 +++++++ .../ui/tests/e2e/specs/dag-trigger.spec.ts | 54 +++++ dev/breeze/doc/05_test_commands.rst | 37 +++ ...utput_setup_check-all-params-in-groups.svg | 4 +- ...utput_setup_check-all-params-in-groups.txt | 2 +- ...output_setup_regenerate-command-images.svg | 2 +- ...output_setup_regenerate-command-images.txt | 2 +- dev/breeze/doc/images/output_testing.svg | 18 +- dev/breeze/doc/images/output_testing.txt | 2 +- .../images/output_testing_ui-e2e-tests.svg | 172 ++++++++++++++ .../images/output_testing_ui-e2e-tests.txt | 1 + .../airflow_breeze/commands/common_options.py | 107 +++++++++ .../commands/testing_commands.py | 135 +++++++++++ .../commands/testing_commands_config.py | 33 +++ 19 files changed, 1129 insertions(+), 11 deletions(-) create mode 100644 airflow-core/src/airflow/ui/tests/e2e/README.md create mode 100644 airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts create mode 100644 airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts create mode 100644 airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts create mode 100644 airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts create mode 100644 dev/breeze/doc/images/output_testing_ui-e2e-tests.svg create mode 100644 dev/breeze/doc/images/output_testing_ui-e2e-tests.txt diff --git a/airflow-core/src/airflow/ui/package.json b/airflow-core/src/airflow/ui/package.json index 49b0bdc1faa9a..def7a1a89fa1c 100644 --- a/airflow-core/src/airflow/ui/package.json +++ b/airflow-core/src/airflow/ui/package.json @@ -16,7 +16,13 @@ "preview": "vite preview", "codegen": "openapi-merge-cli && openapi-rq -i openapi.merged.json -c axios --format prettier -o openapi-gen --operationId", "test": "vitest run", - "coverage": "vitest run --coverage" + "coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:headed": "playwright test --headed", + "test:e2e:report": "playwright show-report", + "test:e2e:install": "playwright install" }, "dependencies": { "@chakra-ui/anatomy": "^2.3.4", @@ -105,7 +111,8 @@ "vite": "^7.1.11", "vite-plugin-css-injected-by-js": "^3.5.2", "vitest": "^3.2.4", - "web-worker": "^1.5.0" + "web-worker": "^1.5.0", + "@playwright/test": "^1.56.1" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/airflow-core/src/airflow/ui/pnpm-lock.yaml b/airflow-core/src/airflow/ui/pnpm-lock.yaml index 36dca4e6b6b97..235fbf26cfd48 100644 --- a/airflow-core/src/airflow/ui/pnpm-lock.yaml +++ b/airflow-core/src/airflow/ui/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: '@eslint/js': specifier: ^9.25.1 version: 9.26.0 + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 '@stylistic/eslint-plugin': specifier: ^2.13.0 version: 2.13.0(eslint@9.26.0(jiti@1.21.7))(typescript@5.8.3) @@ -872,6 +875,11 @@ packages: resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + '@remix-run/router@1.23.0': resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} engines: {node: '>=14.0.0'} @@ -2764,6 +2772,11 @@ packages: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3747,6 +3760,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -5452,6 +5475,10 @@ snapshots: '@pkgr/core@0.2.4': {} + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + '@remix-run/router@1.23.0': {} '@rolldown/pluginutils@1.0.0-beta.32': {} @@ -8102,6 +8129,9 @@ snapshots: dependencies: minipass: 3.3.6 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9324,6 +9354,14 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} diff --git a/airflow-core/src/airflow/ui/tests/e2e/README.md b/airflow-core/src/airflow/ui/tests/e2e/README.md new file mode 100644 index 0000000000000..fef5e0537dc5c --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/README.md @@ -0,0 +1,219 @@ + + +# Airflow UI End-to-End Tests + +This directory contains end-to-end (E2E) tests for the Airflow UI using Playwright. + +## Overview + +These tests focus on **critical user workflows** to catch regressions while maintaining a manageable test suite: + +- ✅ **Authentication flow** - Login/logout functionality +- ✅ **DAG management** - Triggering DAGs and basic operations +- ✅ **Navigation** - Core UI navigation and page loading + +Following the **test pyramid approach**, we maintain a small set of high-value E2E tests while expanding unit test coverage. + +## Quick Start + +### Prerequisites + +- Node.js 18+ and pnpm installed +- Running Airflow instance with test data +- Test user credentials (default: admin/admin) + +### Installation + +```bash +# Install dependencies +pnpm install + +# Install Playwright browsers +pnpm test:e2e:install +``` + +### Running Tests + +```bash +# Run all E2E tests (headless) +pnpm test:e2e + +# Run with visible browser (development) +pnpm test:e2e:headed + +# Run in interactive UI mode (debugging) +pnpm test:e2e:ui + +# Run specific test file +pnpm test:e2e tests/e2e/specs/dag-trigger.spec.ts +``` + +## Test Structure + +``` +tests/e2e/ +├── pages/ # Page Object Models +│ ├── BasePage.ts # Common page functionality +│ ├── LoginPage.ts # Authentication +│ └── DagsPage.ts # DAG operations +└── specs/ # Test specifications + ├── dag-trigger.spec.ts # DAG triggering workflow + └── dag-list.spec.ts # DAG navigation workflow +``` + +## Configuration + +### Environment Variables + +Set these for your test environment: + +```bash +# Test credentials +export TEST_USERNAME=admin +export TEST_PASSWORD=admin + +# Test DAG (should exist in your instance) +export TEST_DAG_ID=example_bash_operator + +# Airflow URL +export AIRFLOW_UI_BASE_URL=http://localhost:8080 +``` + +### Browser Selection + +Tests run on Chromium by default. To test other browsers: + +```bash +# Firefox +pnpm test:e2e --project=firefox + +# WebKit (Safari) +pnpm test:e2e --project=webkit +``` + +## Test Development Guidelines + +### Writing Tests + +1. **Follow Page Object Model** - Keep selectors and actions in page objects +2. **Robust Selectors** - Use multiple fallback selectors for UI flexibility +3. **Explicit Waits** - Always wait for elements/states, never use fixed delays +4. **Clear Test Names** - Describe the user workflow being tested + +### Example Test Structure + +```typescript +test('should complete user workflow', async ({ page }) => { + // Step 1: Setup/Authentication + const loginPage = new LoginPage(page); + await loginPage.login('admin', 'admin'); + + // Step 2: Navigate + const dagsPage = new DagsPage(page); + await dagsPage.navigate(); + + // Step 3: Perform action + await dagsPage.triggerDag('test_dag'); + + // Step 4: Verify result + await dagsPage.verifyDagTriggered('test_dag'); +}); +``` + +### Debugging Failed Tests + +```bash +# Run in debug mode (step through test) +pnpm test:e2e:debug + +# Generate test code interactively +pnpm exec playwright codegen http://localhost:8080 + +# View last test report +pnpm test:e2e:report +``` + +## CI/CD Integration + +Tests run automatically on: + +- Pull requests affecting UI or API code +- Pushes to main branch +- Manual workflow dispatch + +### Artifacts + +On failure, CI uploads: + +- Screenshots of failed tests +- Videos of test execution +- Playwright trace files +- HTML test report + +## Maintenance Notes + +### Selector Strategy + +Page objects use multiple selector strategies for robustness: + +```typescript +// Multiple fallbacks for different UI implementations +this.loginButton = page.locator([ + '[data-testid="login-button"]', // Preferred: test IDs + 'button[type="submit"]', // Semantic: form buttons + 'button:has-text("Login")', // Content: button text + '.login-form button' // Fallback: CSS classes +].join(', ')).first(); +``` + +### Adding New Tests + +1. **Check existing coverage** - Avoid duplicating test scenarios +2. **Focus on user workflows** - Test complete user journeys, not individual components +3. **Consider maintenance cost** - E2E tests are expensive to maintain +4. **Add to CI selectively** - Not every test needs to run on every PR + +## Community Guidelines + +This implementation follows the Airflow community's feedback: + +- **Limited scope** - Critical workflows only (~10-15 scenarios max) +- **TypeScript** - Familiar to UI developers +- **Robust tooling** - Good debugging and failure analysis +- **CI integration** - Smart triggering to avoid slowing development + +## Getting Help + +- **Local development issues** - Check Playwright documentation +- **CI failures** - Review artifacts and trace files +- **Test maintenance** - Refer to Page Object Model patterns +- **Community discussion** - Join #dev channel on Airflow Slack + +## Contributing + +When adding new E2E tests: + +1. Discuss scope on the dev mailing list first +2. Follow existing patterns and conventions +3. Include comprehensive error handling +4. Update documentation for new workflows +5. Test locally across different browsers + +Remember: E2E tests should focus on **critical user journeys** that would be difficult to catch with unit tests alone. diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts new file mode 100644 index 0000000000000..8371565d6e8ff --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts @@ -0,0 +1,59 @@ +/*! + * 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 type { Page, Locator } from '@playwright/test'; + +/** + * Base Page Object + */ +export class BasePage { + readonly page: Page; + readonly welcomeHeading: Locator; + + constructor(page: Page) { + this.page = page; + this.welcomeHeading = page.locator('h2.chakra-heading:has-text("Welcome")'); + } + + async maximizeBrowser(): Promise { + try { + await this.page.setViewportSize({ width: 1920, height: 1080 }); + } catch (error) { + // Viewport size could not be set + } + } + + async waitForPageLoad(): Promise { + await this.page.waitForLoadState('networkidle'); + } + + async navigateTo(path: string): Promise { + await this.page.goto(path); + await this.waitForPageLoad(); + } + + async isLoggedIn(): Promise { + try { + await this.welcomeHeading.waitFor({ timeout: 30000 }); + return true; + } catch { + const currentUrl = this.page.url(); + return !currentUrl.includes('/login'); + } + } +} diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts new file mode 100644 index 0000000000000..e163abdf0e642 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts @@ -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. + */ +import type { Locator } from '@playwright/test'; + +import { BasePage } from './BasePage'; + +/** + * DAGs Page Object + */ +export class DagsPage extends BasePage { + // Core page elements + readonly dagsTable: Locator; + readonly triggerButton: Locator; + readonly confirmButton: Locator; + readonly stateElement: Locator; + + // Page URLs + readonly dagsListUrl = '/dags'; + + constructor(page: any) { + super(page); + this.dagsTable = page.locator('div:has(a[href*="/dags/"])'); + this.triggerButton = page.locator('button[aria-label="Trigger Dag"]:has-text("Trigger")'); + this.confirmButton = page.locator('button:has-text("Trigger")').nth(1); + this.stateElement = page.locator('*:has-text("State") + *').first(); + } + + // URL builders for dynamic paths + getDagDetailUrl(dagName: string): string { + return `/dags/${dagName}`; + } + + getDagRunDetailsUrl(dagName: string, dagRunId: string): string { + return `/dags/${dagName}/runs/${dagRunId}/details`; + } + + + + /** + * Navigate to DAGs list page + */ + async navigate(): Promise { + await this.navigateTo(this.dagsListUrl); + } + + /** + * Navigate to DAG detail page + */ + async navigateToDagDetail(dagName: string): Promise { + await this.navigateTo(this.getDagDetailUrl(dagName)); + } + + /** + * Trigger a DAG run + */ + async triggerDag(dagName: string): Promise { + await this.navigateToDagDetail(dagName); + await this.triggerButton.waitFor({ state: 'visible', timeout: 10000 }); + await this.triggerButton.click(); + const dagRunId = await this.handleTriggerDialog(); + return dagRunId; + } + + private async handleTriggerDialog(): Promise { + await this.page.waitForTimeout(1000); + + const responsePromise = this.page.waitForResponse( + response => { + const url = response.url(); + const method = response.request().method(); + return method === 'POST' && url.includes('dagRuns') && !url.includes('hitlDetails'); + }, + { timeout: 10000 } + ).catch(() => null); + + await this.confirmButton.waitFor({ state: 'visible', timeout: 8000 }); + await this.page.waitForTimeout(2000); + await this.confirmButton.click({ force: true }); + + const apiResponse = await responsePromise; + if (apiResponse) { + try { + const responseBody = await apiResponse.text(); + const responseJson = JSON.parse(responseBody); + if (responseJson.dag_run_id) { + return responseJson.dag_run_id; + } + } catch (error) { + // Response parsing failed + } + } + + return null; + } + + async verifyDagRunStatus(dagName: string, dagRunId: string | null): Promise { + if (!dagRunId) { + return; + } + + await this.page.goto(this.getDagRunDetailsUrl(dagName, dagRunId), { + waitUntil: 'domcontentloaded', + timeout: 15000 + }); + await this.page.waitForTimeout(2000); + + const maxWaitTime = 5 * 60 * 1000; + const checkInterval = 10000; + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitTime) { + const currentStatus = await this.getCurrentDagRunStatus(); + + if (currentStatus === 'success') { + return; + } else if (currentStatus === 'failed') { + throw new Error(`DAG run failed: ${dagRunId}`); + } + + await this.page.waitForTimeout(checkInterval); + await this.page.reload({ waitUntil: 'domcontentloaded' }); + await this.page.waitForTimeout(2000); + } + + throw new Error(`DAG run did not complete within 5 minutes: ${dagRunId}`); + } + + private async getCurrentDagRunStatus(): Promise { + try { + const statusText = await this.stateElement.textContent().catch(() => ''); + const status = statusText?.trim() || ''; + + switch (status) { + case 'Success': + return 'success'; + case 'Failed': + return 'failed'; + case 'Running': + return 'running'; + case 'Queued': + return 'queued'; + default: + return 'unknown'; + } + } catch (error) { + return 'unknown'; + } + } +} diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts new file mode 100644 index 0000000000000..b714a3f9d89dc --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts @@ -0,0 +1,79 @@ +/*! + * 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 { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; + +import { BasePage } from './BasePage'; + +/** + * Login Page Object + */ +export class LoginPage extends BasePage { + readonly usernameInput: Locator; + readonly passwordInput: Locator; + readonly loginButton: Locator; + readonly errorMessage: Locator; + + // Page URLs + readonly loginUrl = '/auth/login'; + + constructor(page: any) { + super(page); + + this.usernameInput = page.locator('input[name="username"]'); + this.passwordInput = page.locator('input[name="password"]'); + this.loginButton = page.locator('button[type="submit"]'); + this.errorMessage = page.locator('span:has-text("Invalid credentials")'); + } + + async navigate(): Promise { + await this.maximizeBrowser(); + await this.navigateTo(this.loginUrl); + } + async navigateAndLogin(username: string, password: string): Promise { + await this.navigate(); + await this.login(username, password); + } + + async login(username: string, password: string): Promise { + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + await this.loginButton.click(); + + try { + await this.page.waitForURL(url => !url.toString().includes('/login'), { timeout: 15000 }); + } catch (error) { + const hasError = await this.errorMessage.isVisible().catch(() => false); + if (hasError) { + throw new Error('Login failed with error message visible'); + } + throw error; + } + } + + + async expectLoginSuccess(): Promise { + const currentUrl = this.page.url(); + if (currentUrl.includes('/login')) { + throw new Error(`Expected to be redirected after login, but still on: ${currentUrl}`); + } + await expect(this.isLoggedIn()).resolves.toBe(true); + } + +} diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts new file mode 100644 index 0000000000000..dfc6dd44a6c1b --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts @@ -0,0 +1,54 @@ +/* eslint-disable unicorn/no-null */ + +/*! + * 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 { LoginPage } from '../pages/LoginPage'; +import { DagsPage } from '../pages/DagsPage'; +import { testConfig } from '../../../playwright.config'; + +/** + * DAG Trigger E2E Tests + */ + +test.describe('DAG Trigger Workflow', () => { + let loginPage: LoginPage; + let dagsPage: DagsPage; + + // Test configuration from centralized config + const testCredentials = testConfig.credentials; + const testDagId = testConfig.testDag.id; + + test.beforeEach(async ({ page }) => { + loginPage = new LoginPage(page); + dagsPage = new DagsPage(page); + }); + + test('should successfully trigger a DAG run', async ({ page }) => { + test.setTimeout(7 * 60 * 1000); + + await loginPage.navigateAndLogin(testCredentials.username, testCredentials.password); + await loginPage.expectLoginSuccess(); + + const dagRunId = await dagsPage.triggerDag(testDagId); + if (dagRunId) { + await dagsPage.verifyDagRunStatus(testDagId, dagRunId); + } + }) +}); diff --git a/dev/breeze/doc/05_test_commands.rst b/dev/breeze/doc/05_test_commands.rst index ce937fc7f49e9..9b062a1b2d201 100644 --- a/dev/breeze/doc/05_test_commands.rst +++ b/dev/breeze/doc/05_test_commands.rst @@ -373,6 +373,43 @@ You can override the ``DOCKER_IMAGE`` environment variable to point to the image The Airflow E2E tests are in ``airflow-e2e-tests/`` folder in the main repo. +Running Airflow UI E2E tests +............................. + +You can use Breeze to run the Airflow UI End-to-End tests using Playwright. These tests validate +critical user workflows in the Airflow web interface across multiple browsers (Chromium, Firefox, WebKit). + +.. code-block:: bash + + breeze testing ui-e2e-tests + +For example, to run a specific test pattern in headed mode: + +.. code-block:: bash + + breeze testing ui-e2e-tests --test-pattern "dag-trigger.spec.ts" --headed + +You can also run tests in different browsers: + +.. code-block:: bash + + breeze testing ui-e2e-tests --browser firefox --headed + +Or run tests in Playwright's UI mode for debugging: + +.. code-block:: bash + + breeze testing ui-e2e-tests --ui-mode + +.. image:: ./images/output_testing_ui-e2e-tests.svg + :target: https://raw.githubusercontent.com/apache/airflow/main/dev/breeze/images/output_testing_ui-e2e-tests.svg + :width: 100% + :alt: Breeze testing ui-e2e-tests + +The tests use Page Object Model pattern and are located in ``airflow-core/src/airflow/ui/tests/e2e/`` folder. +The tests require a running Airflow instance (typically ``http://localhost:28080``) and will install +Playwright browsers automatically if needed. + Running Kubernetes tests ------------------------ diff --git a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg index 535cfda010acd..4f8f0be490d14 100644 --- a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg +++ b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg @@ -226,8 +226,8 @@ testing:airflow-ctl-tests | testing:airflow-e2e-tests | testing:core-integration-tests |                testing:core-tests | testing:docker-compose-tests | testing:helm-tests |                                testing:providers-integration-tests | testing:providers-tests | testing:python-api-client-tests |       -testing:system-tests | testing:task-sdk-integration-tests | testing:task-sdk-tests | workflow-run |     -workflow-run:publish-docs)                                                                              +testing:system-tests | testing:task-sdk-integration-tests | testing:task-sdk-tests |                    +testing:ui-e2e-tests | workflow-run | workflow-run:publish-docs)                                        ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ --verbose-vPrint verbose information about performed steps. diff --git a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt index 7d29d0cc92e04..ca8646984a4ec 100644 --- a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt +++ b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt @@ -1 +1 @@ -803dc5aca58abae33d54a20dc925dcc7 +86067b88fef45eb2c4bbe2fc3914f449 diff --git a/dev/breeze/doc/images/output_setup_regenerate-command-images.svg b/dev/breeze/doc/images/output_setup_regenerate-command-images.svg index 6db63a5817f1e..e1b4cad417fe7 100644 --- a/dev/breeze/doc/images/output_setup_regenerate-command-images.svg +++ b/dev/breeze/doc/images/output_setup_regenerate-command-images.svg @@ -240,7 +240,7 @@ testing:airflow-e2e-tests | testing:core-integration-tests | testing:core-tests |                    testing:docker-compose-tests | testing:helm-tests | testing:providers-integration-tests |            testing:providers-tests | testing:python-api-client-tests | testing:system-tests |                   -testing:task-sdk-integration-tests | testing:task-sdk-tests | workflow-run |                         +testing:task-sdk-integration-tests | testing:task-sdk-tests | testing:ui-e2e-tests | workflow-run |  workflow-run:publish-docs)                                                                           --check-onlyOnly check if some images need to be regenerated. Return 0 if no need or 1 if needed. Cannot be used together with --command flag or --force.                                                             diff --git a/dev/breeze/doc/images/output_setup_regenerate-command-images.txt b/dev/breeze/doc/images/output_setup_regenerate-command-images.txt index c196ea6a478ff..ce4ca86df757e 100644 --- a/dev/breeze/doc/images/output_setup_regenerate-command-images.txt +++ b/dev/breeze/doc/images/output_setup_regenerate-command-images.txt @@ -1 +1 @@ -b5a6a5ce57a3a3c4943edfd2d433e4b5 +e38ccdb7e2f6de7b7b183cbaefb54c1a diff --git a/dev/breeze/doc/images/output_testing.svg b/dev/breeze/doc/images/output_testing.svg index 062b54dcdc818..60192bbfea0d5 100644 --- a/dev/breeze/doc/images/output_testing.svg +++ b/dev/breeze/doc/images/output_testing.svg @@ -1,4 +1,4 @@ - + python-api-client-tests                          Run python api client tests.                                      airflow-e2e-tests                                Run Airflow E2E tests.                                            ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ UI Tests ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +ui-e2e-tests               Run UI End-to-End tests using Playwright.                                               +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/dev/breeze/doc/images/output_testing.txt b/dev/breeze/doc/images/output_testing.txt index c6391afcf285f..0aa3a4ec3ea16 100644 --- a/dev/breeze/doc/images/output_testing.txt +++ b/dev/breeze/doc/images/output_testing.txt @@ -1 +1 @@ -b56243c165c9fe541a65f1960fb0043d +7807d360b63d3a298f60662bb1c234d9 diff --git a/dev/breeze/doc/images/output_testing_ui-e2e-tests.svg b/dev/breeze/doc/images/output_testing_ui-e2e-tests.svg new file mode 100644 index 0000000000000..b3ba8a0647df9 --- /dev/null +++ b/dev/breeze/doc/images/output_testing_ui-e2e-tests.svg @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Command: testing ui-e2e-tests + + + + + + + + + + +Usage:breeze testing ui-e2e-tests[OPTIONS] [EXTRA_PLAYWRIGHT_ARGS]... + +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: chromium] +--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. +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + diff --git a/dev/breeze/doc/images/output_testing_ui-e2e-tests.txt b/dev/breeze/doc/images/output_testing_ui-e2e-tests.txt new file mode 100644 index 0000000000000..1d7d001ef08c5 --- /dev/null +++ b/dev/breeze/doc/images/output_testing_ui-e2e-tests.txt @@ -0,0 +1 @@ +b5902cd2e8a82b71a76a859e2643d379 diff --git a/dev/breeze/src/airflow_breeze/commands/common_options.py b/dev/breeze/src/airflow_breeze/commands/common_options.py index 7382c2fd528b2..0bf77ce228398 100644 --- a/dev/breeze/src/airflow_breeze/commands/common_options.py +++ b/dev/breeze/src/airflow_breeze/commands/common_options.py @@ -557,3 +557,110 @@ def _normalize_platform(ctx: click.core.Context, param: click.core.Option, value callback=_normalize_platform, type=BetterChoice(SINGLE_PLATFORMS), ) + + +# UI E2E Testing Options + +option_airflow_ui_base_url = click.option( + "--airflow-ui-base-url", + help="Base URL for Airflow UI during e2e tests", + default="http://localhost:28080", + show_default=True, + envvar="AIRFLOW_UI_BASE_URL", +) + +option_browser = click.option( + "--browser", + help="Browser to use for e2e tests", + type=BetterChoice(["chromium", "firefox", "webkit", "all"]), + default="chromium", + show_default=True, +) + +option_headed = click.option( + "--headed", + help="Run e2e tests in headed mode (show browser window)", + is_flag=True, +) + +option_debug_e2e = click.option( + "--debug-e2e", + help="Run e2e tests in debug mode", + is_flag=True, +) + +option_ui_mode = click.option( + "--ui-mode", + help="Run e2e tests in Playwright UI mode", + is_flag=True, +) + +option_update_snapshots = click.option( + "--update-snapshots", + help="Update visual regression snapshots", + is_flag=True, +) + +option_test_pattern = click.option( + "--test-pattern", + help="Glob pattern to filter test files", + type=str, +) + +option_e2e_workers = click.option( + "--workers", + help="Number of parallel workers for e2e tests", + type=int, + default=1, + show_default=True, +) + +option_e2e_timeout = click.option( + "--timeout", + help="Test timeout in milliseconds", + type=int, + default=60000, + show_default=True, +) + +option_e2e_reporter = click.option( + "--reporter", + help="Test reporter for e2e tests", + type=BetterChoice(["list", "dot", "line", "json", "junit", "html", "github"]), + default="html", + show_default=True, +) + +option_test_admin_username = click.option( + "--test-admin-username", + help="Admin username for e2e tests", + default="admin", + show_default=True, + envvar="TEST_ADMIN_USERNAME", +) + +option_test_admin_password = click.option( + "--test-admin-password", + help="Admin password for e2e tests", + default="admin", + show_default=True, + envvar="TEST_ADMIN_PASSWORD", +) + +option_skip_airflow_start = click.option( + "--skip-airflow-start", + help="Skip starting Airflow services (assume already running)", + is_flag=True, +) + +option_keep_airflow_running = click.option( + "--keep-airflow-running", + help="Keep Airflow services running after tests", + is_flag=True, +) + +option_force_reinstall_deps = click.option( + "--force-reinstall-deps", + help="Force reinstall UI dependencies", + is_flag=True, +) diff --git a/dev/breeze/src/airflow_breeze/commands/testing_commands.py b/dev/breeze/src/airflow_breeze/commands/testing_commands.py index 0223e80749779..20f63de3e818b 100644 --- a/dev/breeze/src/airflow_breeze/commands/testing_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/testing_commands.py @@ -30,19 +30,27 @@ from airflow_breeze.commands.ci_image_commands import rebuild_or_pull_ci_image_if_needed from airflow_breeze.commands.common_options import ( + option_airflow_ui_base_url, option_allow_pre_releases, option_backend, + option_browser, option_clean_airflow_installation, option_core_integration, option_db_reset, + option_debug_e2e, option_debug_resources, option_downgrade_pendulum, option_downgrade_sqlalchemy, option_dry_run, + option_e2e_reporter, + option_e2e_timeout, + option_e2e_workers, option_excluded_providers, option_force_lowest_dependencies, + option_force_reinstall_deps, option_forward_credentials, option_github_repository, + option_headed, option_image_name, option_include_success_outputs, option_install_airflow_with_constraints, @@ -58,6 +66,10 @@ option_run_in_parallel, option_skip_cleanup, option_skip_db_tests, + option_test_admin_password, + option_test_admin_username, + option_test_pattern, + option_ui_mode, option_upgrade_boto, option_upgrade_sqlalchemy, option_use_airflow_version, @@ -1381,6 +1393,129 @@ def airflow_e2e_tests( sys.exit(return_code) +@group_for_testing.command( + name="ui-e2e-tests", + help="Run UI End-to-End tests using Playwright.", + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + ), +) +@option_airflow_ui_base_url +@option_browser +@option_debug_e2e +@option_dry_run +@option_e2e_reporter +@option_e2e_timeout +@option_e2e_workers +@option_force_reinstall_deps +@option_headed +@option_test_admin_password +@option_test_admin_username +@option_test_pattern +@option_ui_mode +@option_verbose +@click.argument("extra_playwright_args", nargs=-1, type=click.Path(path_type=str)) +def ui_e2e_tests( + airflow_ui_base_url: str, + browser: str, + debug_e2e: bool, + reporter: str, + timeout: int, + workers: int, + force_reinstall_deps: bool, + headed: bool, + test_admin_password: str, + test_admin_username: str, + test_pattern: str, + ui_mode: bool, + extra_playwright_args: tuple, +): + """Run UI end-to-end tests using Playwright.""" + import sys + from pathlib import Path + + from airflow_breeze.utils.console import get_console + from airflow_breeze.utils.run_utils import run_command + from airflow_breeze.utils.shared_options import get_dry_run, get_verbose + + perform_environment_checks() + + airflow_root = Path(__file__).resolve().parents[5] + ui_dir = airflow_root / "airflow-core" / "src" / "airflow" / "ui" + + 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", + } + + 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 + + try: + result = run_command( + playwright_cmd, cwd=ui_dir, env=env_vars, verbose_override=get_verbose(), check=False + ) + + report_path = ui_dir / "playwright-report" / "index.html" + if report_path.exists(): + get_console().print(f"[info]Report: file://{report_path}[/]") + + if result.returncode != 0: + sys.exit(result.returncode) + + except Exception as e: + get_console().print(f"[error]{str(e)}[/]") + sys.exit(1) + + class TimeoutHandler: def __init__(self, shell_params: ShellParams, terminated_on_timeout_output_list: list[bool]): # Initialize the timeout handler with shell parameters and a list to track terminated outputs 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 a8637b71e3619..e8abd9a8ae29c 100644 --- a/dev/breeze/src/airflow_breeze/commands/testing_commands_config.py +++ b/dev/breeze/src/airflow_breeze/commands/testing_commands_config.py @@ -164,6 +164,10 @@ "airflow-e2e-tests", ], }, + { + "name": "UI Tests", + "commands": ["ui-e2e-tests"], + }, ] TESTING_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = { @@ -298,4 +302,33 @@ ], } ], + "breeze testing ui-e2e-tests": [ + { + "name": "UI End-to-End test options", + "options": [ + "--browser", + "--headed", + "--debug-e2e", + "--ui-mode", + "--test-pattern", + "--workers", + "--timeout", + "--reporter", + ], + }, + { + "name": "Test environment for UI tests", + "options": [ + "--airflow-ui-base-url", + "--test-admin-username", + "--test-admin-password", + ], + }, + { + "name": "Advanced flags for UI e2e tests", + "options": [ + "--force-reinstall-deps", + ], + }, + ], } From 65641c28c23b8cfb20241a7ebc8a7dd3b728704f Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Fri, 21 Nov 2025 12:01:18 +0530 Subject: [PATCH 3/8] updating readme --- .../src/airflow/ui/tests/e2e/README.md | 196 +++--------------- 1 file changed, 34 insertions(+), 162 deletions(-) diff --git a/airflow-core/src/airflow/ui/tests/e2e/README.md b/airflow-core/src/airflow/ui/tests/e2e/README.md index fef5e0537dc5c..a30f5c9ce2167 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/README.md +++ b/airflow-core/src/airflow/ui/tests/e2e/README.md @@ -19,201 +19,73 @@ # Airflow UI End-to-End Tests -This directory contains end-to-end (E2E) tests for the Airflow UI using Playwright. +UI automation tests using Playwright for critical Airflow workflows. -## Overview +## Prerequisites -These tests focus on **critical user workflows** to catch regressions while maintaining a manageable test suite: +**Requires running Airflow with example DAGs:** -- ✅ **Authentication flow** - Login/logout functionality -- ✅ **DAG management** - Triggering DAGs and basic operations -- ✅ **Navigation** - Core UI navigation and page loading +- Airflow running on `http://localhost:28080` (default) +- Admin user: `admin/admin` +- Example DAGs loaded (uses `example_bash_operator`) -Following the **test pyramid approach**, we maintain a small set of high-value E2E tests while expanding unit test coverage. +## Running Tests -## Quick Start - -### Prerequisites - -- Node.js 18+ and pnpm installed -- Running Airflow instance with test data -- Test user credentials (default: admin/admin) - -### Installation +### Using Breeze ```bash -# Install dependencies -pnpm install +# Basic run +breeze testing ui-e2e-tests + +# Specific test with browser visible +breeze testing ui-e2e-tests --test-pattern "dag-trigger.spec.ts" --headed -# Install Playwright browsers -pnpm test:e2e:install +# Different browsers +breeze testing ui-e2e-tests --browser firefox --headed +breeze testing ui-e2e-tests --browser webkit --headed ``` -### Running Tests +### Using pnpm directly ```bash -# Run all E2E tests (headless) -pnpm test:e2e - -# Run with visible browser (development) -pnpm test:e2e:headed +cd airflow-core/src/airflow/ui -# Run in interactive UI mode (debugging) -pnpm test:e2e:ui +# Install dependencies +pnpm install +pnpm exec playwright install -# Run specific test file -pnpm test:e2e tests/e2e/specs/dag-trigger.spec.ts +# Run tests +pnpm test:e2e:headed # Show browser +pnpm test:e2e:ui # Interactive debugging ``` ## Test Structure ``` tests/e2e/ -├── pages/ # Page Object Models -│ ├── BasePage.ts # Common page functionality -│ ├── LoginPage.ts # Authentication -│ └── DagsPage.ts # DAG operations -└── specs/ # Test specifications - ├── dag-trigger.spec.ts # DAG triggering workflow - └── dag-list.spec.ts # DAG navigation workflow +├── pages/ # Page Object Models +└── specs/ # Test files ``` ## Configuration -### Environment Variables - -Set these for your test environment: +Set environment variables if needed: ```bash -# Test credentials +export AIRFLOW_UI_BASE_URL=http://localhost:28080 export TEST_USERNAME=admin export TEST_PASSWORD=admin - -# Test DAG (should exist in your instance) export TEST_DAG_ID=example_bash_operator - -# Airflow URL -export AIRFLOW_UI_BASE_URL=http://localhost:8080 ``` -### Browser Selection - -Tests run on Chromium by default. To test other browsers: +## Debugging ```bash -# Firefox -pnpm test:e2e --project=firefox +# Step through tests +breeze testing ui-e2e-tests --debug-e2e -# WebKit (Safari) -pnpm test:e2e --project=webkit +# View test report +pnpm exec playwright show-report ``` -## Test Development Guidelines - -### Writing Tests - -1. **Follow Page Object Model** - Keep selectors and actions in page objects -2. **Robust Selectors** - Use multiple fallback selectors for UI flexibility -3. **Explicit Waits** - Always wait for elements/states, never use fixed delays -4. **Clear Test Names** - Describe the user workflow being tested - -### Example Test Structure - -```typescript -test('should complete user workflow', async ({ page }) => { - // Step 1: Setup/Authentication - const loginPage = new LoginPage(page); - await loginPage.login('admin', 'admin'); - - // Step 2: Navigate - const dagsPage = new DagsPage(page); - await dagsPage.navigate(); - - // Step 3: Perform action - await dagsPage.triggerDag('test_dag'); - - // Step 4: Verify result - await dagsPage.verifyDagTriggered('test_dag'); -}); -``` - -### Debugging Failed Tests - -```bash -# Run in debug mode (step through test) -pnpm test:e2e:debug - -# Generate test code interactively -pnpm exec playwright codegen http://localhost:8080 - -# View last test report -pnpm test:e2e:report -``` - -## CI/CD Integration - -Tests run automatically on: - -- Pull requests affecting UI or API code -- Pushes to main branch -- Manual workflow dispatch - -### Artifacts - -On failure, CI uploads: - -- Screenshots of failed tests -- Videos of test execution -- Playwright trace files -- HTML test report - -## Maintenance Notes - -### Selector Strategy - -Page objects use multiple selector strategies for robustness: - -```typescript -// Multiple fallbacks for different UI implementations -this.loginButton = page.locator([ - '[data-testid="login-button"]', // Preferred: test IDs - 'button[type="submit"]', // Semantic: form buttons - 'button:has-text("Login")', // Content: button text - '.login-form button' // Fallback: CSS classes -].join(', ')).first(); -``` - -### Adding New Tests - -1. **Check existing coverage** - Avoid duplicating test scenarios -2. **Focus on user workflows** - Test complete user journeys, not individual components -3. **Consider maintenance cost** - E2E tests are expensive to maintain -4. **Add to CI selectively** - Not every test needs to run on every PR - -## Community Guidelines - -This implementation follows the Airflow community's feedback: - -- **Limited scope** - Critical workflows only (~10-15 scenarios max) -- **TypeScript** - Familiar to UI developers -- **Robust tooling** - Good debugging and failure analysis -- **CI integration** - Smart triggering to avoid slowing development - -## Getting Help - -- **Local development issues** - Check Playwright documentation -- **CI failures** - Review artifacts and trace files -- **Test maintenance** - Refer to Page Object Model patterns -- **Community discussion** - Join #dev channel on Airflow Slack - -## Contributing - -When adding new E2E tests: - -1. Discuss scope on the dev mailing list first -2. Follow existing patterns and conventions -3. Include comprehensive error handling -4. Update documentation for new workflows -5. Test locally across different browsers - -Remember: E2E tests should focus on **critical user journeys** that would be difficult to catch with unit tests alone. +Find test artifacts in `test-results/` and reports in `playwright-report/`. From adc40ec8f1f159e5590d2a916705054366bb2a0c Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Fri, 21 Nov 2025 17:33:06 +0530 Subject: [PATCH 4/8] fix static checks --- .../airflow/ui/tests/e2e/pages/BasePage.ts | 40 ++-- .../airflow/ui/tests/e2e/pages/DagsPage.ts | 180 +++++++++++------- .../airflow/ui/tests/e2e/pages/LoginPage.ts | 79 +++++--- .../ui/tests/e2e/specs/dag-trigger.spec.ts | 28 ++- airflow-core/src/airflow/ui/tsconfig.dev.json | 2 +- 5 files changed, 196 insertions(+), 133 deletions(-) diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts index 8371565d6e8ff..ae63c31ddec4b 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts @@ -16,44 +16,46 @@ * specific language governing permissions and limitations * under the License. */ -import type { Page, Locator } from '@playwright/test'; +import type { Page, Locator } from "@playwright/test"; /** * Base Page Object */ export class BasePage { - readonly page: Page; - readonly welcomeHeading: Locator; + public readonly page: Page; + public readonly welcomeHeading: Locator; - constructor(page: Page) { + public constructor(page: Page) { this.page = page; this.welcomeHeading = page.locator('h2.chakra-heading:has-text("Welcome")'); } - async maximizeBrowser(): Promise { + public async isLoggedIn(): Promise { try { - await this.page.setViewportSize({ width: 1920, height: 1080 }); - } catch (error) { - // Viewport size could not be set + await this.welcomeHeading.waitFor({ timeout: 30_000 }); + + return true; + } catch { + const currentUrl = this.page.url(); + + return !currentUrl.includes("/login"); } } - async waitForPageLoad(): Promise { - await this.page.waitForLoadState('networkidle'); + public async maximizeBrowser(): Promise { + try { + await this.page.setViewportSize({ height: 1080, width: 1920 }); + } catch { + // Viewport size could not be set + } } - async navigateTo(path: string): Promise { + public async navigateTo(path: string): Promise { await this.page.goto(path); await this.waitForPageLoad(); } - async isLoggedIn(): Promise { - try { - await this.welcomeHeading.waitFor({ timeout: 30000 }); - return true; - } catch { - const currentUrl = this.page.url(); - return !currentUrl.includes('/login'); - } + public async waitForPageLoad(): Promise { + await this.page.waitForLoadState("networkidle"); } } diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts index e163abdf0e642..14cff3e979cd9 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts @@ -16,24 +16,27 @@ * specific language governing permissions and limitations * under the License. */ -import type { Locator } from '@playwright/test'; +import type { Locator, Page } from "@playwright/test"; -import { BasePage } from './BasePage'; +import { BasePage } from "./BasePage"; /** * DAGs Page Object */ export class DagsPage extends BasePage { - // Core page elements - readonly dagsTable: Locator; - readonly triggerButton: Locator; - readonly confirmButton: Locator; - readonly stateElement: Locator; - // Page URLs - readonly dagsListUrl = '/dags'; + public static get dagsListUrl(): string { + return "/dags"; + } - constructor(page: any) { + // Core page elements + public readonly confirmButton: Locator; + public readonly dagsTable: Locator; + public readonly stateElement: Locator; + public readonly triggerButton: Locator; + + public constructor(page: Page) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call super(page); this.dagsTable = page.locator('div:has(a[href*="/dags/"])'); this.triggerButton = page.locator('button[aria-label="Trigger Dag"]:has-text("Trigger")'); @@ -42,99 +45,74 @@ export class DagsPage extends BasePage { } // URL builders for dynamic paths - getDagDetailUrl(dagName: string): string { + public static getDagDetailUrl(dagName: string): string { return `/dags/${dagName}`; } - getDagRunDetailsUrl(dagName: string, dagRunId: string): string { + public static getDagRunDetailsUrl(dagName: string, dagRunId: string): string { return `/dags/${dagName}/runs/${dagRunId}/details`; } - - /** * Navigate to DAGs list page */ - async navigate(): Promise { - await this.navigateTo(this.dagsListUrl); + public async navigate(): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await this.navigateTo(DagsPage.dagsListUrl); } /** * Navigate to DAG detail page */ - async navigateToDagDetail(dagName: string): Promise { - await this.navigateTo(this.getDagDetailUrl(dagName)); + public async navigateToDagDetail(dagName: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await this.navigateTo(DagsPage.getDagDetailUrl(dagName)); } /** * Trigger a DAG run */ - async triggerDag(dagName: string): Promise { + public async triggerDag(dagName: string): Promise { await this.navigateToDagDetail(dagName); - await this.triggerButton.waitFor({ state: 'visible', timeout: 10000 }); + await this.triggerButton.waitFor({ state: "visible", timeout: 10_000 }); await this.triggerButton.click(); const dagRunId = await this.handleTriggerDialog(); - return dagRunId; - } - - private async handleTriggerDialog(): Promise { - await this.page.waitForTimeout(1000); - - const responsePromise = this.page.waitForResponse( - response => { - const url = response.url(); - const method = response.request().method(); - return method === 'POST' && url.includes('dagRuns') && !url.includes('hitlDetails'); - }, - { timeout: 10000 } - ).catch(() => null); - await this.confirmButton.waitFor({ state: 'visible', timeout: 8000 }); - await this.page.waitForTimeout(2000); - await this.confirmButton.click({ force: true }); - - const apiResponse = await responsePromise; - if (apiResponse) { - try { - const responseBody = await apiResponse.text(); - const responseJson = JSON.parse(responseBody); - if (responseJson.dag_run_id) { - return responseJson.dag_run_id; - } - } catch (error) { - // Response parsing failed - } - } - - return null; + return dagRunId; } - async verifyDagRunStatus(dagName: string, dagRunId: string | null): Promise { - if (!dagRunId) { + public async verifyDagRunStatus(dagName: string, dagRunId: string | null): Promise { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (dagRunId === null || dagRunId === undefined || dagRunId === "") { return; } - await this.page.goto(this.getDagRunDetailsUrl(dagName, dagRunId), { - waitUntil: 'domcontentloaded', - timeout: 15000 + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.page.goto(DagsPage.getDagRunDetailsUrl(dagName, dagRunId), { + timeout: 15_000, + waitUntil: "domcontentloaded", }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await this.page.waitForTimeout(2000); const maxWaitTime = 5 * 60 * 1000; - const checkInterval = 10000; + const checkInterval = 10_000; const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { const currentStatus = await this.getCurrentDagRunStatus(); - if (currentStatus === 'success') { + if (currentStatus === "success") { return; - } else if (currentStatus === 'failed') { + } else if (currentStatus === "failed") { throw new Error(`DAG run failed: ${dagRunId}`); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await this.page.waitForTimeout(checkInterval); - await this.page.reload({ waitUntil: 'domcontentloaded' }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.page.reload({ waitUntil: "domcontentloaded" }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await this.page.waitForTimeout(2000); } @@ -143,23 +121,77 @@ export class DagsPage extends BasePage { private async getCurrentDagRunStatus(): Promise { try { - const statusText = await this.stateElement.textContent().catch(() => ''); - const status = statusText?.trim() || ''; + const statusText = await this.stateElement.textContent().catch(() => ""); + const status = statusText?.trim() ?? ""; switch (status) { - case 'Success': - return 'success'; - case 'Failed': - return 'failed'; - case 'Running': - return 'running'; - case 'Queued': - return 'queued'; + case "Failed": + return "failed"; + case "Queued": + return "queued"; + case "Running": + return "running"; + case "Success": + return "success"; default: - return 'unknown'; + return "unknown"; } - } catch (error) { - return 'unknown'; + } catch { + return "unknown"; } } + + private async handleTriggerDialog(): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.page.waitForTimeout(1000); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + const responsePromise = this.page + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + .waitForResponse( + (response) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const url = response.url(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const method = response.request().method(); + + return ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/strict-boolean-expressions + method === "POST" && Boolean(url.includes("dagRuns")) && Boolean(!url.includes("hitlDetails")) + ); + }, + { timeout: 10_000 }, + ) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + .catch(() => undefined); + + await this.confirmButton.waitFor({ state: "visible", timeout: 8000 }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.page.waitForTimeout(2000); + await this.confirmButton.click({ force: true }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const apiResponse = await responsePromise; + + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (apiResponse) { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const responseBody = await apiResponse.text(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument + const responseJson = JSON.parse(responseBody); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (Boolean(responseJson.dag_run_id)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access + return responseJson.dag_run_id; + } + } catch { + // Response parsing failed + } + } + + // eslint-disable-next-line unicorn/no-null + return null; + } } diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts index b714a3f9d89dc..09d9c0cc4f77e 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts @@ -16,24 +16,27 @@ * specific language governing permissions and limitations * under the License. */ -import { expect } from '@playwright/test'; -import type { Locator } from '@playwright/test'; +import { expect } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; -import { BasePage } from './BasePage'; +import { BasePage } from "./BasePage"; /** * Login Page Object */ export class LoginPage extends BasePage { - readonly usernameInput: Locator; - readonly passwordInput: Locator; - readonly loginButton: Locator; - readonly errorMessage: Locator; - // Page URLs - readonly loginUrl = '/auth/login'; + public static get loginUrl(): string { + return "/auth/login"; + } + + public readonly errorMessage: Locator; + public readonly loginButton: Locator; + public readonly passwordInput: Locator; + public readonly usernameInput: Locator; - constructor(page: any) { + public constructor(page: Page) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call super(page); this.usernameInput = page.locator('input[name="username"]'); @@ -42,38 +45,54 @@ export class LoginPage extends BasePage { this.errorMessage = page.locator('span:has-text("Invalid credentials")'); } - async navigate(): Promise { - await this.maximizeBrowser(); - await this.navigateTo(this.loginUrl); - } - async navigateAndLogin(username: string, password: string): Promise { - await this.navigate(); - await this.login(username, password); - } + public async expectLoginSuccess(): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const currentUrl: string = this.page.url(); + + if (currentUrl.includes("/login")) { + throw new Error(`Expected to be redirected after login, but still on: ${currentUrl}`); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + const isLoggedIn: boolean = await this.isLoggedIn(); - async login(username: string, password: string): Promise { + expect(isLoggedIn).toBe(true); + } + public async login(username: string, password: string): Promise { await this.usernameInput.fill(username); await this.passwordInput.fill(password); await this.loginButton.click(); try { - await this.page.waitForURL(url => !url.toString().includes('/login'), { timeout: 15000 }); - } catch (error) { - const hasError = await this.errorMessage.isVisible().catch(() => false); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.page.waitForURL( + (url: URL) => { + const urlString: string = url.toString(); + + return !urlString.includes("/login"); + }, + { timeout: 15_000 }, + ); + } catch (error: unknown) { + const hasError: boolean = await this.errorMessage.isVisible().catch(() => false); + if (hasError) { - throw new Error('Login failed with error message visible'); + throw new Error("Login failed with error message visible"); } + throw error; } } - - async expectLoginSuccess(): Promise { - const currentUrl = this.page.url(); - if (currentUrl.includes('/login')) { - throw new Error(`Expected to be redirected after login, but still on: ${currentUrl}`); - } - await expect(this.isLoggedIn()).resolves.toBe(true); + public async navigate(): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await this.maximizeBrowser(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await this.navigateTo(LoginPage.loginUrl); } + public async navigateAndLogin(username: string, password: string): Promise { + await this.navigate(); + await this.login(username, password); + } } diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts index dfc6dd44a6c1b..bfab4babbffeb 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts @@ -1,5 +1,3 @@ -/* eslint-disable unicorn/no-null */ - /*! * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -18,37 +16,49 @@ * specific language governing permissions and limitations * under the License. */ -import { test, expect } from '@playwright/test'; -import { LoginPage } from '../pages/LoginPage'; -import { DagsPage } from '../pages/DagsPage'; -import { testConfig } from '../../../playwright.config'; +import { test } from "@playwright/test"; + +import { testConfig } from "../../../playwright.config"; +import { DagsPage } from "../pages/DagsPage"; +import { LoginPage } from "../pages/LoginPage"; /** * DAG Trigger E2E Tests */ -test.describe('DAG Trigger Workflow', () => { +test.describe("DAG Trigger Workflow", () => { let loginPage: LoginPage; let dagsPage: DagsPage; // Test configuration from centralized config + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access const testCredentials = testConfig.credentials; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access const testDagId = testConfig.testDag.id; + // eslint-disable-next-line @typescript-eslint/require-await test.beforeEach(async ({ page }) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call loginPage = new LoginPage(page); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call dagsPage = new DagsPage(page); }); - test('should successfully trigger a DAG run', async ({ page }) => { + test("should successfully trigger a DAG run", async () => { test.setTimeout(7 * 60 * 1000); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await loginPage.navigateAndLogin(testCredentials.username, testCredentials.password); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await loginPage.expectLoginSuccess(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access const dagRunId = await dagsPage.triggerDag(testDagId); + + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (dagRunId) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await dagsPage.verifyDagRunStatus(testDagId, dagRunId); } - }) + }); }); diff --git a/airflow-core/src/airflow/ui/tsconfig.dev.json b/airflow-core/src/airflow/ui/tsconfig.dev.json index a8107d588fd7a..def547860e8fb 100644 --- a/airflow-core/src/airflow/ui/tsconfig.dev.json +++ b/airflow-core/src/airflow/ui/tsconfig.dev.json @@ -8,5 +8,5 @@ "strict": true, "target": "ESNext" }, - "include": ["./*.ts", "./*.js", "./rules/*.js", "./rules/*.ts"] + "include": ["./*.ts", "./*.js", "./rules/*.js", "./rules/*.ts", "./tests/**/*.ts"] } From 3042807bde4fc1af417956fd4b04ea29290c51db Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Fri, 21 Nov 2025 20:59:15 +0530 Subject: [PATCH 5/8] fix static checks --- .../src/airflow/ui/playwright.config.ts | 104 ++++++++++++++++++ .../airflow/ui/tests/e2e/pages/DagsPage.ts | 30 ++--- .../airflow/ui/tests/e2e/pages/LoginPage.ts | 9 +- .../ui/tests/e2e/specs/dag-trigger.spec.ts | 18 ++- airflow-core/src/airflow/ui/tsconfig.app.json | 2 +- 5 files changed, 123 insertions(+), 40 deletions(-) create mode 100644 airflow-core/src/airflow/ui/playwright.config.ts diff --git a/airflow-core/src/airflow/ui/playwright.config.ts b/airflow-core/src/airflow/ui/playwright.config.ts new file mode 100644 index 0000000000000..7393fa5ded1ee --- /dev/null +++ b/airflow-core/src/airflow/ui/playwright.config.ts @@ -0,0 +1,104 @@ +/*! + * 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 { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright configuration for Airflow UI End-to-End Tests + */ +export const testConfig = { + credentials: { + password: process.env.TEST_PASSWORD ?? "admin", + username: process.env.TEST_USERNAME ?? "admin", + }, + testDag: { + id: process.env.TEST_DAG_ID ?? "example_bash_operator", + }, +}; + +export default defineConfig({ + expect: { + timeout: 5000, + }, + forbidOnly: process.env.CI !== undefined && process.env.CI !== "", + fullyParallel: true, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + launchOptions: { + args: [ + "--start-maximized", + "--disable-web-security", + "--disable-features=VizDisplayCompositor", + "--window-size=1920,1080", + "--window-position=0,0", + ], + channel: "chrome", + ignoreDefaultArgs: ["--enable-automation"], + }, + }, + }, + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + launchOptions: { + args: [ + "--width=1920", + "--height=1080", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-web-security", + ], + }, + }, + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + launchOptions: { + args: [], + }, + }, + }, + ], + reporter: [ + ["html", { outputFolder: "playwright-report" }], + ["json", { outputFile: "test-results/results.json" }], + process.env.CI !== undefined && process.env.CI !== "" ? ["github"] : ["list"], + ], + + retries: process.env.CI !== undefined && process.env.CI !== "" ? 2 : 0, + + testDir: "./tests/e2e/specs", + + timeout: 30_000, + use: { + actionTimeout: 10_000, + baseURL: process.env.AIRFLOW_UI_BASE_URL ?? "http://localhost:28080", + screenshot: "only-on-failure", + trace: "on-first-retry", + video: "retain-on-failure", + viewport: undefined, + }, + + workers: process.env.CI !== undefined && process.env.CI !== "" ? 2 : undefined, +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts index 14cff3e979cd9..a432c46ab8985 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts @@ -18,7 +18,7 @@ */ import type { Locator, Page } from "@playwright/test"; -import { BasePage } from "./BasePage"; +import { BasePage } from "./BasePage.ts"; /** * DAGs Page Object @@ -36,7 +36,6 @@ export class DagsPage extends BasePage { public readonly triggerButton: Locator; public constructor(page: Page) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call super(page); this.dagsTable = page.locator('div:has(a[href*="/dags/"])'); this.triggerButton = page.locator('button[aria-label="Trigger Dag"]:has-text("Trigger")'); @@ -57,7 +56,6 @@ export class DagsPage extends BasePage { * Navigate to DAGs list page */ public async navigate(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call await this.navigateTo(DagsPage.dagsListUrl); } @@ -65,7 +63,6 @@ export class DagsPage extends BasePage { * Navigate to DAG detail page */ public async navigateToDagDetail(dagName: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call await this.navigateTo(DagsPage.getDagDetailUrl(dagName)); } @@ -87,12 +84,11 @@ export class DagsPage extends BasePage { return; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await this.page.goto(DagsPage.getDagRunDetailsUrl(dagName, dagRunId), { timeout: 15_000, waitUntil: "domcontentloaded", }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.page.waitForTimeout(2000); const maxWaitTime = 5 * 60 * 1000; @@ -108,11 +104,10 @@ export class DagsPage extends BasePage { throw new Error(`DAG run failed: ${dagRunId}`); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await this.page.waitForTimeout(checkInterval); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.page.reload({ waitUntil: "domcontentloaded" }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.page.waitForTimeout(2000); } @@ -142,43 +137,36 @@ export class DagsPage extends BasePage { } private async handleTriggerDialog(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await this.page.waitForTimeout(1000); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const responsePromise = this.page - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + .waitForResponse( (response) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access const url = response.url(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const method = response.request().method(); return ( - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/strict-boolean-expressions method === "POST" && Boolean(url.includes("dagRuns")) && Boolean(!url.includes("hitlDetails")) ); }, { timeout: 10_000 }, ) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + .catch(() => undefined); await this.confirmButton.waitFor({ state: "visible", timeout: 8000 }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.page.waitForTimeout(2000); await this.confirmButton.click({ force: true }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const apiResponse = await responsePromise; - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (apiResponse) { try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access const responseBody = await apiResponse.text(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const responseJson = JSON.parse(responseBody); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts index 09d9c0cc4f77e..510c4716b9cf5 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts @@ -19,7 +19,7 @@ import { expect } from "@playwright/test"; import type { Locator, Page } from "@playwright/test"; -import { BasePage } from "./BasePage"; +import { BasePage } from "./BasePage.ts"; /** * Login Page Object @@ -36,7 +36,6 @@ export class LoginPage extends BasePage { public readonly usernameInput: Locator; public constructor(page: Page) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call super(page); this.usernameInput = page.locator('input[name="username"]'); @@ -46,14 +45,12 @@ export class LoginPage extends BasePage { } public async expectLoginSuccess(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access const currentUrl: string = this.page.url(); if (currentUrl.includes("/login")) { throw new Error(`Expected to be redirected after login, but still on: ${currentUrl}`); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const isLoggedIn: boolean = await this.isLoggedIn(); expect(isLoggedIn).toBe(true); @@ -64,7 +61,6 @@ export class LoginPage extends BasePage { await this.loginButton.click(); try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await this.page.waitForURL( (url: URL) => { const urlString: string = url.toString(); @@ -85,9 +81,8 @@ export class LoginPage extends BasePage { } public async navigate(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call await this.maximizeBrowser(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await this.navigateTo(LoginPage.loginUrl); } diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts index bfab4babbffeb..45e8134a19ba5 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts @@ -18,9 +18,9 @@ */ import { test } from "@playwright/test"; -import { testConfig } from "../../../playwright.config"; -import { DagsPage } from "../pages/DagsPage"; -import { LoginPage } from "../pages/LoginPage"; +import { testConfig } from "../../../playwright.config.ts"; +import { DagsPage } from "../pages/DagsPage.ts"; +import { LoginPage } from "../pages/LoginPage.ts"; /** * DAG Trigger E2E Tests @@ -31,33 +31,29 @@ test.describe("DAG Trigger Workflow", () => { let dagsPage: DagsPage; // Test configuration from centralized config - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const testCredentials = testConfig.credentials; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const testDagId = testConfig.testDag.id; // eslint-disable-next-line @typescript-eslint/require-await test.beforeEach(async ({ page }) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call loginPage = new LoginPage(page); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + dagsPage = new DagsPage(page); }); test("should successfully trigger a DAG run", async () => { test.setTimeout(7 * 60 * 1000); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await loginPage.navigateAndLogin(testCredentials.username, testCredentials.password); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await loginPage.expectLoginSuccess(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access const dagRunId = await dagsPage.triggerDag(testDagId); // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (dagRunId) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access await dagsPage.verifyDagRunStatus(testDagId, dagRunId); } }); diff --git a/airflow-core/src/airflow/ui/tsconfig.app.json b/airflow-core/src/airflow/ui/tsconfig.app.json index d19c8a8651dcb..82568019ccabe 100644 --- a/airflow-core/src/airflow/ui/tsconfig.app.json +++ b/airflow-core/src/airflow/ui/tsconfig.app.json @@ -27,5 +27,5 @@ "openapi/*": ["./openapi-gen/*"] } }, - "include": ["src"] + "include": ["src", "tests/**/*.ts", "playwright.config.ts"] } From 130d75cd60eeeb35e9e4f3ecef3cfdf4f61fa12c Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Fri, 21 Nov 2025 21:09:41 +0530 Subject: [PATCH 6/8] execlude e2e test from Vitest --- airflow-core/src/airflow/ui/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/airflow-core/src/airflow/ui/vite.config.ts b/airflow-core/src/airflow/ui/vite.config.ts index 7e49f32a1c822..7dab7ad59c513 100644 --- a/airflow-core/src/airflow/ui/vite.config.ts +++ b/airflow-core/src/airflow/ui/vite.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ }, css: true, environment: "happy-dom", + exclude: ["**/tests/e2e/**"], globals: true, mockReset: true, restoreMocks: true, From 4ea26cac7d4279608b5cc1a37f6c3e2d1f9ba166 Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Fri, 28 Nov 2025 11:35:01 +0530 Subject: [PATCH 7/8] implement review comments --- airflow-core/src/airflow/ui/.gitignore | 6 ++++++ airflow-core/src/airflow/ui/tests/e2e/README.md | 4 ++-- .../src/airflow/ui/tests/e2e/pages/DagsPage.ts | 3 +-- .../src/airflow/ui/tests/e2e/pages/LoginPage.ts | 6 +++--- .../airflow/ui/tests/e2e/specs/dag-trigger.spec.ts | 14 +++++--------- airflow-core/src/airflow/ui/tsconfig.app.json | 4 +++- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/airflow-core/src/airflow/ui/.gitignore b/airflow-core/src/airflow/ui/.gitignore index 1f7e9bfb98934..1b6dfa5ecf028 100644 --- a/airflow-core/src/airflow/ui/.gitignore +++ b/airflow-core/src/airflow/ui/.gitignore @@ -1 +1,7 @@ openapi.merged.json + +# Playwright E2E test results and reports +/test-results/ +/playwright-report/ +/tests/e2e/test-results/ +/tests/e2e/playwright-report/ diff --git a/airflow-core/src/airflow/ui/tests/e2e/README.md b/airflow-core/src/airflow/ui/tests/e2e/README.md index a30f5c9ce2167..3c805f3b3f4a5 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/README.md +++ b/airflow-core/src/airflow/ui/tests/e2e/README.md @@ -25,7 +25,7 @@ UI automation tests using Playwright for critical Airflow workflows. **Requires running Airflow with example DAGs:** -- Airflow running on `http://localhost:28080` (default) +- Airflow UI running on `http://localhost:28080` (default) - Admin user: `admin/admin` - Example DAGs loaded (uses `example_bash_operator`) @@ -85,7 +85,7 @@ export TEST_DAG_ID=example_bash_operator breeze testing ui-e2e-tests --debug-e2e # View test report -pnpm exec playwright show-report +pnpm test:e2e:report ``` Find test artifacts in `test-results/` and reports in `playwright-report/`. diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts index a432c46ab8985..4a122e01b3566 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts @@ -17,8 +17,7 @@ * under the License. */ import type { Locator, Page } from "@playwright/test"; - -import { BasePage } from "./BasePage.ts"; +import { BasePage } from "tests/e2e/pages/BasePage"; /** * DAGs Page Object diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts index 510c4716b9cf5..eda41657f8362 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/LoginPage.ts @@ -18,8 +18,7 @@ */ import { expect } from "@playwright/test"; import type { Locator, Page } from "@playwright/test"; - -import { BasePage } from "./BasePage.ts"; +import { BasePage } from "tests/e2e/pages/BasePage"; /** * Login Page Object @@ -40,7 +39,8 @@ export class LoginPage extends BasePage { this.usernameInput = page.locator('input[name="username"]'); this.passwordInput = page.locator('input[name="password"]'); - this.loginButton = page.locator('button[type="submit"]'); + // Support both SimpleAuthManager and FabAuthManager login buttons + this.loginButton = page.locator('button[type="submit"], input[type="submit"]'); this.errorMessage = page.locator('span:has-text("Invalid credentials")'); } diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts index 45e8134a19ba5..b7c57de3cb700 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-trigger.spec.ts @@ -17,10 +17,9 @@ * under the License. */ import { test } from "@playwright/test"; - -import { testConfig } from "../../../playwright.config.ts"; -import { DagsPage } from "../pages/DagsPage.ts"; -import { LoginPage } from "../pages/LoginPage.ts"; +import { testConfig } from "playwright.config"; +import { DagsPage } from "tests/e2e/pages/DagsPage"; +import { LoginPage } from "tests/e2e/pages/LoginPage"; /** * DAG Trigger E2E Tests @@ -36,10 +35,8 @@ test.describe("DAG Trigger Workflow", () => { const testDagId = testConfig.testDag.id; - // eslint-disable-next-line @typescript-eslint/require-await - test.beforeEach(async ({ page }) => { + test.beforeEach(({ page }) => { loginPage = new LoginPage(page); - dagsPage = new DagsPage(page); }); @@ -52,8 +49,7 @@ test.describe("DAG Trigger Workflow", () => { const dagRunId = await dagsPage.triggerDag(testDagId); - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (dagRunId) { + if (Boolean(dagRunId)) { await dagsPage.verifyDagRunStatus(testDagId, dagRunId); } }); diff --git a/airflow-core/src/airflow/ui/tsconfig.app.json b/airflow-core/src/airflow/ui/tsconfig.app.json index 82568019ccabe..59bc815960aa6 100644 --- a/airflow-core/src/airflow/ui/tsconfig.app.json +++ b/airflow-core/src/airflow/ui/tsconfig.app.json @@ -24,7 +24,9 @@ "baseUrl": ".", "paths": { "src/*": ["./src/*"], - "openapi/*": ["./openapi-gen/*"] + "openapi/*": ["./openapi-gen/*"], + "tests/*": ["./tests/*"], + "playwright.config": ["./playwright.config.ts"] } }, "include": ["src", "tests/**/*.ts", "playwright.config.ts"] From d411d27125cf9977a99d9d93edb3e9d26fcfed6a Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Fri, 28 Nov 2025 17:43:06 +0530 Subject: [PATCH 8/8] implement review comments --- airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts | 7 +++---- dev/breeze/doc/images/output_testing_ui-e2e-tests.svg | 2 +- dev/breeze/doc/images/output_testing_ui-e2e-tests.txt | 2 +- dev/breeze/src/airflow_breeze/commands/common_options.py | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts index 4a122e01b3566..6880dd9af50d4 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts @@ -19,6 +19,8 @@ import type { Locator, Page } from "@playwright/test"; import { BasePage } from "tests/e2e/pages/BasePage"; +import type { DAGRunResponse } from "openapi/requests/types.gen"; + /** * DAGs Page Object */ @@ -165,12 +167,9 @@ export class DagsPage extends BasePage { if (apiResponse) { try { const responseBody = await apiResponse.text(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const responseJson = JSON.parse(responseBody); + const responseJson = JSON.parse(responseBody) as DAGRunResponse; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (Boolean(responseJson.dag_run_id)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access return responseJson.dag_run_id; } } catch { 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 b3ba8a0647df9..53b4f84dfc39c 100644 --- a/dev/breeze/doc/images/output_testing_ui-e2e-tests.svg +++ b/dev/breeze/doc/images/output_testing_ui-e2e-tests.svg @@ -144,7 +144,7 @@ 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: chromium] +--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 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 1d7d001ef08c5..475047eb78e98 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 @@ -b5902cd2e8a82b71a76a859e2643d379 +37da219fd2514ea3a6027056c903360c diff --git a/dev/breeze/src/airflow_breeze/commands/common_options.py b/dev/breeze/src/airflow_breeze/commands/common_options.py index 0f1c66270c516..9e058176598a4 100644 --- a/dev/breeze/src/airflow_breeze/commands/common_options.py +++ b/dev/breeze/src/airflow_breeze/commands/common_options.py @@ -581,7 +581,7 @@ def _normalize_platform(ctx: click.core.Context, param: click.core.Option, value "--browser", help="Browser to use for e2e tests", type=BetterChoice(["chromium", "firefox", "webkit", "all"]), - default="chromium", + default="all", show_default=True, )