From 45dfd1a3a42b83646e746b668f86de847e490d2f Mon Sep 17 00:00:00 2001 From: lin121291 <4jp33f9e@gmail.com> Date: Tue, 30 Dec 2025 11:46:29 +0800 Subject: [PATCH 1/3] Add E2E tests for DAG Runs page - Add DagRunsPage page object with locators and actions - Add test specs for DAG runs list, filtering, and navigation - Cover DAG run state changes --- .../airflow/ui/tests/e2e/pages/DagRunsPage.ts | 173 ++++++++++++++++++ .../ui/tests/e2e/specs/dag-runs.spec.ts | 48 +++++ 2 files changed, 221 insertions(+) create mode 100644 airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts create mode 100644 airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts new file mode 100644 index 0000000000000..9010e73d83d88 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts @@ -0,0 +1,173 @@ +/*! + * 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, type Locator, type Page } from "@playwright/test"; +import { BasePage } from "tests/e2e/pages/BasePage"; + +/** + * Page Object for the DAG Runs page (/dag_runs) + */ +export class DagRunsPage extends BasePage { + public static get dagRunsUrl(): string { + return "/dag_runs"; + } + + public readonly dagRunsTable: Locator; + + public constructor(page: Page) { + super(page); + this.dagRunsTable = page.locator('table, div[role="table"]'); + } + + /** + * Navigate to DAG Runs page + */ + public async navigate(): Promise { + await this.navigateTo(DagRunsPage.dagRunsUrl); + await this.page.waitForURL(/.*dag_runs/, { timeout: 15_000 }); + await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); + + // Wait for data to load - check if we have rows or "No Dag Runs found" message + const rows = this.dagRunsTable.locator('tbody tr:not(.no-data), div[role="row"]:not(:first-child)'); + const noDataMessage = this.page.locator('text="No Dag Runs found"'); + + await Promise.race([ + rows + .first() + .waitFor({ state: "visible", timeout: 10_000 }) + .catch(() => { + // Ignore error - either rows or no data message will appear + }), + noDataMessage.waitFor({ state: "visible", timeout: 10_000 }).catch(() => { + // Ignore error - either rows or no data message will appear + }), + ]); + } + + /** + * Verify that DAG runs exist in the table + */ + public async verifyDagRunsExist(): Promise { + const rows = this.dagRunsTable.locator('tbody tr:not(.no-data), div[role="row"]:not(:first-child)'); + + expect(await rows.count()).toBeGreaterThan(0); + } + + /** + * Verify that filtering works + */ + public async verifyFilteringWorks(): Promise { + const addFilterButton = this.page.locator( + 'button:has-text("Filter"), button:has-text("Add Filter"), button[aria-label*="filter" i]', + ); + + await expect(addFilterButton.first()).toBeVisible({ timeout: 5000 }); + await addFilterButton.first().click(); + + await this.page.waitForTimeout(1000); + + const stateFilterOption = this.page.locator(':text("State")').first(); + + await expect(stateFilterOption).toBeVisible({ timeout: 5000 }); + + await stateFilterOption.click(); + await this.page.waitForTimeout(500); + + await expect(this.dagRunsTable).toBeVisible(); + } + + /** + * Verify that run details are displayed correctly + * Checks: DAG ID, Run ID, State, Start Date, End Date + */ + public async verifyRunDetailsDisplay(): Promise { + const firstRow = this.dagRunsTable.locator("tbody tr, div[role='row']:not(:first-child)").first(); + + const dagIdLink = firstRow.locator("a[href*='/dags/']").first(); + + if ((await dagIdLink.count()) > 0) { + await expect(dagIdLink).toBeVisible(); + expect((await dagIdLink.textContent())?.trim()).toBeTruthy(); + } + + // Verify Run ID + const runIdLink = firstRow.locator("a[href*='/runs/']").first(); + + await expect(runIdLink).toBeVisible(); + expect((await runIdLink.textContent())?.trim()).toBeTruthy(); + + // Verify State - look for text content containing state keywords + const stateCell = firstRow + .locator('td, div[role="cell"]') + .filter({ hasText: /running|success|failed|queued/i }); + + await expect(stateCell.first()).toBeVisible(); + + // Verify dates (start/end time) - look for time elements or date patterns + const timeElements = firstRow.locator("time"); + + if ((await timeElements.count()) > 0) { + await expect(timeElements.first()).toBeVisible(); + } else { + // Fallback to text content search + const cellTexts = await firstRow.locator("td, div[role='cell']").allTextContents(); + const hasDateFormat = cellTexts.some((text) => + /\d{4}(?:-\d{2}){2}|(?:\d{1,2}\/){2}\d{4}|(?:\d{1,2}:){2}\d{2}/.test(text), + ); + + expect(hasDateFormat).toBeTruthy(); + } + } + + /** + * Verify that different run states are visually distinct + */ + public async verifyStateVisualDistinction(): Promise { + const stateBadges = this.dagRunsTable.locator('[class*="badge"], [class*="Badge"]'); + + await stateBadges.first().waitFor({ state: "visible", timeout: 5000 }); + + const badgeCount = await stateBadges.count(); + + expect(badgeCount).toBeGreaterThan(0); + + const stateStyles = new Map(); + const limit = Math.min(badgeCount, 10); + + for (let i = 0; i < limit; i++) { + const badge = stateBadges.nth(i); + const text = (await badge.textContent())?.trim().toLowerCase(); + const bgColor = await badge.evaluate((el) => window.getComputedStyle(el).backgroundColor); + + if (text !== "" && text !== undefined) { + stateStyles.set(text, bgColor); + } + } + + // Verify at least one state badge exists + expect(stateStyles.size).toBeGreaterThanOrEqual(1); + + // If multiple states exist, verify they have different colors + if (stateStyles.size > 1) { + const colors = [...stateStyles.values()]; + const uniqueColors = new Set(colors); + + expect(uniqueColors.size).toBeGreaterThan(1); + } + } +} diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts new file mode 100644 index 0000000000000..58421b5c1d7c2 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts @@ -0,0 +1,48 @@ +/*! + * 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 } from "@playwright/test"; +import { DagRunsPage } from "tests/e2e/pages/DagRunsPage"; + +test.describe("DAG Runs Page", () => { + let dagRunsPage: DagRunsPage; + + test.beforeEach(({ page }) => { + dagRunsPage = new DagRunsPage(page); + }); + + test("should display the DAG runs table with data", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyDagRunsExist(); + }); + + test("should display run details: run ID, state, and dates", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyRunDetailsDisplay(); + }); + + test("should display different run states with visual badges", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyStateVisualDistinction(); + }); + + test("should have filtering functionality", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyFilteringWorks(); + }); +}); From 4848ade9a731316fb0e013a990a951b99332563e Mon Sep 17 00:00:00 2001 From: lin121291 <4jp33f9e@gmail.com> Date: Mon, 12 Jan 2026 01:07:23 +0800 Subject: [PATCH 2/3] feat: enhance DAG runs E2E tests with proper filtering and pagination verification - Set 60s timeout for WebKit compatibility - Add beforeAll hook to create test data via API (failed/success states, multiple DAG IDs) - Replace waitForTimeout with proper element waits - Verify state and DAG ID filters return correct results - Add pagination test with offset/limit parameters - Remove redundant visual badge test --- .../airflow/ui/tests/e2e/pages/DagRunsPage.ts | 239 +++++++++++++----- .../ui/tests/e2e/specs/dag-runs.spec.ts | 112 +++++++- 2 files changed, 289 insertions(+), 62 deletions(-) diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts index 9010e73d83d88..ad89d01e48345 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts @@ -19,9 +19,6 @@ import { expect, type Locator, type Page } from "@playwright/test"; import { BasePage } from "tests/e2e/pages/BasePage"; -/** - * Page Object for the DAG Runs page (/dag_runs) - */ export class DagRunsPage extends BasePage { public static get dagRunsUrl(): string { return "/dag_runs"; @@ -34,6 +31,43 @@ export class DagRunsPage extends BasePage { this.dagRunsTable = page.locator('table, div[role="table"]'); } + /** + * Apply a filter by selecting a filter type and value + */ + public async applyFilter(filterName: string, filterValue: string): Promise { + const addFilterButton = this.page.locator( + 'button:has-text("Filter"), button:has-text("Add Filter"), button[aria-label*="filter" i]', + ); + + await expect(addFilterButton.first()).toBeVisible({ timeout: 5000 }); + await addFilterButton.first().click(); + + const filterMenu = this.page.locator('[role="menu"], [role="menuitem"]'); + + await expect(filterMenu.first()).toBeVisible({ timeout: 5000 }); + + const filterOption = this.page.locator(`[role="menuitem"]:has-text("${filterName}")`).first(); + + await expect(filterOption).toBeVisible({ timeout: 5000 }); + await filterOption.click(); + + const filterPill = this.page.locator(`[role="combobox"], button:has-text("${filterName}:")`); + + await expect(filterPill.first()).toBeVisible({ timeout: 5000 }); + await filterPill.first().click(); + + const dropdown = this.page.locator('[role="option"], [role="listbox"]'); + + await expect(dropdown.first()).toBeVisible({ timeout: 5000 }); + + const valueOption = this.page.locator(`[role="option"]:has-text("${filterValue}")`).first(); + + await expect(valueOption).toBeVisible({ timeout: 5000 }); + await valueOption.click(); + + await this.page.waitForLoadState("networkidle"); + } + /** * Navigate to DAG Runs page */ @@ -42,21 +76,78 @@ export class DagRunsPage extends BasePage { await this.page.waitForURL(/.*dag_runs/, { timeout: 15_000 }); await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); - // Wait for data to load - check if we have rows or "No Dag Runs found" message const rows = this.dagRunsTable.locator('tbody tr:not(.no-data), div[role="row"]:not(:first-child)'); const noDataMessage = this.page.locator('text="No Dag Runs found"'); - await Promise.race([ - rows - .first() - .waitFor({ state: "visible", timeout: 10_000 }) - .catch(() => { - // Ignore error - either rows or no data message will appear - }), - noDataMessage.waitFor({ state: "visible", timeout: 10_000 }).catch(() => { - // Ignore error - either rows or no data message will appear - }), - ]); + try { + await expect(rows.first().or(noDataMessage)).toBeVisible({ timeout: 10_000 }); + } catch { + await expect(this.dagRunsTable).toBeVisible(); + } + } + + /** + * Verify that DAG ID filtering works correctly + */ + public async verifyDagIdFiltering(dagIdPattern: string): Promise { + const addFilterButton = this.page.locator( + 'button:has-text("Filter"), button:has-text("Add Filter"), button[aria-label*="filter" i]', + ); + + await expect(addFilterButton.first()).toBeVisible({ timeout: 5000 }); + await addFilterButton.first().click(); + + const filterMenu = this.page.locator('[role="menu"], [role="menuitem"]'); + + await expect(filterMenu.first()).toBeVisible({ timeout: 5000 }); + + const dagIdFilterOption = this.page.locator('[role="menuitem"]:has-text("DAG ID")').first(); + + await expect(dagIdFilterOption).toBeVisible({ timeout: 5000 }); + + const inputsBeforeClick = await this.page.locator("input").count(); + + await dagIdFilterOption.click(); + + await expect(filterMenu.first()).not.toBeVisible({ timeout: 5000 }); + + await this.page.waitForFunction( + (expectedCount: number) => document.querySelectorAll("input").length > expectedCount, + inputsBeforeClick, + { timeout: 5000 }, + ); + + const filterInput = this.page.locator("input").last(); + + await expect(filterInput).toBeVisible({ timeout: 5000 }); + + await filterInput.fill(dagIdPattern); + await filterInput.press("Enter"); + + await this.page.waitForURL(/.*dag_id_pattern=.*/, { timeout: 10_000 }); + await this.page.waitForLoadState("networkidle"); + await expect(this.dagRunsTable).toBeVisible(); + + const rowsAfterFilter = this.dagRunsTable.locator( + 'tbody tr:not(.no-data), div[role="row"]:not(:first-child)', + ); + const countAfter = await rowsAfterFilter.count(); + + // Verify that we have results after filtering + expect(countAfter).toBeGreaterThan(0); + + const rows = this.dagRunsTable.locator('tbody tr:not(.no-data), div[role="row"]:not(:first-child)'); + const rowsToCheck = Math.min(countAfter, 5); + + for (let i = 0; i < rowsToCheck; i++) { + const row = rows.nth(i); + // Get the first link in the row which should be the DAG ID + const dagIdLink = row.locator("a[href*='/dags/']").first(); + const dagIdText = await dagIdLink.textContent(); + + expect(dagIdText).toBeTruthy(); + expect(dagIdText).toContain(dagIdPattern); + } } /** @@ -69,31 +160,70 @@ export class DagRunsPage extends BasePage { } /** - * Verify that filtering works + * Verify pagination works correctly with offset and limit parameters */ - public async verifyFilteringWorks(): Promise { - const addFilterButton = this.page.locator( - 'button:has-text("Filter"), button:has-text("Add Filter"), button[aria-label*="filter" i]', - ); + public async verifyPagination(limit: number): Promise { + // Navigate with limit parameter + await this.navigateTo(`${DagRunsPage.dagRunsUrl}?offset=0&limit=${limit}`); + await this.page.waitForURL(/.*dag_runs/, { timeout: 10_000 }); + await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); - await expect(addFilterButton.first()).toBeVisible({ timeout: 5000 }); - await addFilterButton.first().click(); + const rows = this.dagRunsTable.locator('tbody tr:not(.no-data), div[role="row"]:not(:first-child)'); + const rowCount = await rows.count(); - await this.page.waitForTimeout(1000); + // Verify we have results + expect(rowCount).toBeGreaterThan(0); - const stateFilterOption = this.page.locator(':text("State")').first(); + // Verify pagination controls exist + const paginationInfo = this.page.locator("text=/\\d+\\s*-\\s*\\d+\\s*of\\s*\\d+/i"); - await expect(stateFilterOption).toBeVisible({ timeout: 5000 }); + if ((await paginationInfo.count()) > 0) { + await expect(paginationInfo.first()).toBeVisible(); - await stateFilterOption.click(); - await this.page.waitForTimeout(500); + const paginationText = (await paginationInfo.first().textContent()) ?? ""; - await expect(this.dagRunsTable).toBeVisible(); + expect(paginationText).toBeTruthy(); + + // Extract the total count from pagination text (e.g., "1 - 10 of 50") + const regex = /(\d+)\s*-\s*(\d+)\s*of\s*(\d+)/i; + const match = regex.exec(paginationText); + + if (match?.[1] !== undefined && match[2] !== undefined && match[3] !== undefined) { + const start = parseInt(match[1], 10); + const end = parseInt(match[2], 10); + const total = parseInt(match[3], 10); + + expect(start).toBeGreaterThan(0); + expect(end).toBeGreaterThanOrEqual(start); + expect(total).toBeGreaterThanOrEqual(end); + + // Test that offset changes when navigating to next page + const initialStart = start; + + await this.navigateTo(`${DagRunsPage.dagRunsUrl}?offset=${limit}&limit=${limit}`); + await this.page.waitForURL(/.*dag_runs/, { timeout: 10_000 }); + await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); + + const paginationInfoPage2 = this.page.locator("text=/\\d+\\s*-\\s*\\d+\\s*of\\s*\\d+/i"); + + if ((await paginationInfoPage2.count()) > 0) { + const paginationText2 = (await paginationInfoPage2.first().textContent()) ?? ""; + const regex2 = /(\d+)\s*-\s*(\d+)\s*of\s*(\d+)/i; + const match2 = regex2.exec(paginationText2); + + if (match2?.[1] !== undefined) { + const startPage2 = parseInt(match2[1], 10); + + // Verify that the start position changed + expect(startPage2).toBeGreaterThan(initialStart); + } + } + } + } } /** * Verify that run details are displayed correctly - * Checks: DAG ID, Run ID, State, Start Date, End Date */ public async verifyRunDetailsDisplay(): Promise { const firstRow = this.dagRunsTable.locator("tbody tr, div[role='row']:not(:first-child)").first(); @@ -105,26 +235,22 @@ export class DagRunsPage extends BasePage { expect((await dagIdLink.textContent())?.trim()).toBeTruthy(); } - // Verify Run ID const runIdLink = firstRow.locator("a[href*='/runs/']").first(); await expect(runIdLink).toBeVisible(); expect((await runIdLink.textContent())?.trim()).toBeTruthy(); - // Verify State - look for text content containing state keywords const stateCell = firstRow .locator('td, div[role="cell"]') .filter({ hasText: /running|success|failed|queued/i }); await expect(stateCell.first()).toBeVisible(); - // Verify dates (start/end time) - look for time elements or date patterns const timeElements = firstRow.locator("time"); if ((await timeElements.count()) > 0) { await expect(timeElements.first()).toBeVisible(); } else { - // Fallback to text content search const cellTexts = await firstRow.locator("td, div[role='cell']").allTextContents(); const hasDateFormat = cellTexts.some((text) => /\d{4}(?:-\d{2}){2}|(?:\d{1,2}\/){2}\d{4}|(?:\d{1,2}:){2}\d{2}/.test(text), @@ -135,39 +261,38 @@ export class DagRunsPage extends BasePage { } /** - * Verify that different run states are visually distinct + * Verify that state filtering works correctly */ - public async verifyStateVisualDistinction(): Promise { - const stateBadges = this.dagRunsTable.locator('[class*="badge"], [class*="Badge"]'); - - await stateBadges.first().waitFor({ state: "visible", timeout: 5000 }); + public async verifyStateFiltering(expectedState: string): Promise { + const rowsBeforeFilter = this.dagRunsTable.locator( + 'tbody tr:not(.no-data), div[role="row"]:not(:first-child)', + ); + const countBefore = await rowsBeforeFilter.count(); - const badgeCount = await stateBadges.count(); + await this.applyFilter("State", expectedState); - expect(badgeCount).toBeGreaterThan(0); + await this.page.waitForURL(/.*state=.*/, { timeout: 10_000 }); + await this.page.waitForLoadState("networkidle"); + await expect(this.dagRunsTable).toBeVisible(); - const stateStyles = new Map(); - const limit = Math.min(badgeCount, 10); + const rowsAfterFilter = this.dagRunsTable.locator( + 'tbody tr:not(.no-data), div[role="row"]:not(:first-child)', + ); + const countAfter = await rowsAfterFilter.count(); - for (let i = 0; i < limit; i++) { - const badge = stateBadges.nth(i); - const text = (await badge.textContent())?.trim().toLowerCase(); - const bgColor = await badge.evaluate((el) => window.getComputedStyle(el).backgroundColor); + if (countAfter > 0) { + expect(countAfter).toBeLessThanOrEqual(countBefore); - if (text !== "" && text !== undefined) { - stateStyles.set(text, bgColor); - } - } + const stateCells = this.dagRunsTable + .locator('[class*="badge"], [class*="Badge"]') + .filter({ hasText: new RegExp(expectedState, "i") }); - // Verify at least one state badge exists - expect(stateStyles.size).toBeGreaterThanOrEqual(1); + await expect(stateCells.first()).toBeVisible({ timeout: 5000 }); - // If multiple states exist, verify they have different colors - if (stateStyles.size > 1) { - const colors = [...stateStyles.values()]; - const uniqueColors = new Set(colors); + const badgeCount = await stateCells.count(); - expect(uniqueColors.size).toBeGreaterThan(1); + expect(badgeCount).toBeGreaterThan(0); + expect(badgeCount).toBeLessThanOrEqual(countAfter); } } } diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts index 58421b5c1d7c2..3d61b0fe6a0c1 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts @@ -16,11 +16,104 @@ * specific language governing permissions and limitations * under the License. */ -import { test } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import { AUTH_FILE, testConfig } from "playwright.config"; import { DagRunsPage } from "tests/e2e/pages/DagRunsPage"; test.describe("DAG Runs Page", () => { + test.setTimeout(60_000); + let dagRunsPage: DagRunsPage; + const testDagId1 = testConfig.testDag.id; + const testDagId2 = "example_python_operator"; + let failedRunId: string; + let successRunId: string; + + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + const baseUrl = process.env.AIRFLOW_UI_BASE_URL ?? "http://localhost:8080"; + + const timestamp = Date.now(); + + // Trigger first DAG run + const runId1 = `test_run_failed_${timestamp}`; + const logicalDate1 = new Date(timestamp).toISOString(); + const triggerResponse1 = await page.request.post(`${baseUrl}/api/v2/dags/${testDagId1}/dagRuns`, { + data: JSON.stringify({ + dag_run_id: runId1, + logical_date: logicalDate1, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + expect(triggerResponse1.ok()).toBeTruthy(); + const runData1 = (await triggerResponse1.json()) as { dag_run_id: string }; + + failedRunId = runData1.dag_run_id; + + // Mark the first run as failed + const patchResponse = await page.request.patch( + `${baseUrl}/api/v2/dags/${testDagId1}/dagRuns/${failedRunId}`, + { + data: JSON.stringify({ state: "failed" }), + headers: { + "Content-Type": "application/json", + }, + }, + ); + + expect(patchResponse.ok()).toBeTruthy(); + + // Trigger second DAG run for success state + const runId2 = `test_run_success_${timestamp}`; + const logicalDate2 = new Date(timestamp + 60_000).toISOString(); + const triggerResponse2 = await page.request.post(`${baseUrl}/api/v2/dags/${testDagId1}/dagRuns`, { + data: JSON.stringify({ + dag_run_id: runId2, + logical_date: logicalDate2, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + expect(triggerResponse2.ok()).toBeTruthy(); + const runData2 = (await triggerResponse2.json()) as { dag_run_id: string }; + + successRunId = runData2.dag_run_id; + + // Mark the second run as success + const patchResponse2 = await page.request.patch( + `${baseUrl}/api/v2/dags/${testDagId1}/dagRuns/${successRunId}`, + { + data: JSON.stringify({ state: "success" }), + headers: { + "Content-Type": "application/json", + }, + }, + ); + + expect(patchResponse2.ok()).toBeTruthy(); + + // Trigger a run for a different DAG + const runId3 = `test_run_other_dag_${timestamp}`; + const logicalDate3 = new Date(timestamp + 120_000).toISOString(); + + await page.request.post(`${baseUrl}/api/v2/dags/${testDagId2}/dagRuns`, { + data: JSON.stringify({ + dag_run_id: runId3, + logical_date: logicalDate3, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + await context.close(); + }); test.beforeEach(({ page }) => { dagRunsPage = new DagRunsPage(page); @@ -36,13 +129,22 @@ test.describe("DAG Runs Page", () => { await dagRunsPage.verifyRunDetailsDisplay(); }); - test("should display different run states with visual badges", async () => { + test("should filter by failed state correctly", async () => { await dagRunsPage.navigate(); - await dagRunsPage.verifyStateVisualDistinction(); + await dagRunsPage.verifyStateFiltering("Failed"); }); - test("should have filtering functionality", async () => { + test("should filter by success state correctly", async () => { await dagRunsPage.navigate(); - await dagRunsPage.verifyFilteringWorks(); + await dagRunsPage.verifyStateFiltering("Success"); + }); + + test("should filter by DAG ID correctly", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyDagIdFiltering(testDagId1); + }); + + test("should support pagination with offset and limit", async () => { + await dagRunsPage.verifyPagination(3); }); }); From c9f3a392b2836e182260e463526198f3b23e0781 Mon Sep 17 00:00:00 2001 From: vatsrahul1001 Date: Tue, 13 Jan 2026 15:42:22 +0530 Subject: [PATCH 3/3] fix(ui/e2e): improve DAG runs page tests reliability --- .../airflow/ui/tests/e2e/pages/DagRunsPage.ts | 232 +++++------------- .../ui/tests/e2e/specs/dag-runs.spec.ts | 36 ++- 2 files changed, 77 insertions(+), 191 deletions(-) diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts index ad89d01e48345..080def6218d47 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts @@ -32,117 +32,39 @@ export class DagRunsPage extends BasePage { } /** - * Apply a filter by selecting a filter type and value - */ - public async applyFilter(filterName: string, filterValue: string): Promise { - const addFilterButton = this.page.locator( - 'button:has-text("Filter"), button:has-text("Add Filter"), button[aria-label*="filter" i]', - ); - - await expect(addFilterButton.first()).toBeVisible({ timeout: 5000 }); - await addFilterButton.first().click(); - - const filterMenu = this.page.locator('[role="menu"], [role="menuitem"]'); - - await expect(filterMenu.first()).toBeVisible({ timeout: 5000 }); - - const filterOption = this.page.locator(`[role="menuitem"]:has-text("${filterName}")`).first(); - - await expect(filterOption).toBeVisible({ timeout: 5000 }); - await filterOption.click(); - - const filterPill = this.page.locator(`[role="combobox"], button:has-text("${filterName}:")`); - - await expect(filterPill.first()).toBeVisible({ timeout: 5000 }); - await filterPill.first().click(); - - const dropdown = this.page.locator('[role="option"], [role="listbox"]'); - - await expect(dropdown.first()).toBeVisible({ timeout: 5000 }); - - const valueOption = this.page.locator(`[role="option"]:has-text("${filterValue}")`).first(); - - await expect(valueOption).toBeVisible({ timeout: 5000 }); - await valueOption.click(); - - await this.page.waitForLoadState("networkidle"); - } - - /** - * Navigate to DAG Runs page + * Navigate to DAG Runs page and wait for data to load */ public async navigate(): Promise { await this.navigateTo(DagRunsPage.dagRunsUrl); await this.page.waitForURL(/.*dag_runs/, { timeout: 15_000 }); await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); - const rows = this.dagRunsTable.locator('tbody tr:not(.no-data), div[role="row"]:not(:first-child)'); + const dataLink = this.dagRunsTable.locator("a[href*='/dags/']").first(); const noDataMessage = this.page.locator('text="No Dag Runs found"'); - try { - await expect(rows.first().or(noDataMessage)).toBeVisible({ timeout: 10_000 }); - } catch { - await expect(this.dagRunsTable).toBeVisible(); - } + await expect(dataLink.or(noDataMessage)).toBeVisible({ timeout: 30_000 }); } /** - * Verify that DAG ID filtering works correctly + * Verify DAG ID filtering via URL parameters */ public async verifyDagIdFiltering(dagIdPattern: string): Promise { - const addFilterButton = this.page.locator( - 'button:has-text("Filter"), button:has-text("Add Filter"), button[aria-label*="filter" i]', - ); - - await expect(addFilterButton.first()).toBeVisible({ timeout: 5000 }); - await addFilterButton.first().click(); - - const filterMenu = this.page.locator('[role="menu"], [role="menuitem"]'); - - await expect(filterMenu.first()).toBeVisible({ timeout: 5000 }); - - const dagIdFilterOption = this.page.locator('[role="menuitem"]:has-text("DAG ID")').first(); - - await expect(dagIdFilterOption).toBeVisible({ timeout: 5000 }); - - const inputsBeforeClick = await this.page.locator("input").count(); - - await dagIdFilterOption.click(); - - await expect(filterMenu.first()).not.toBeVisible({ timeout: 5000 }); - - await this.page.waitForFunction( - (expectedCount: number) => document.querySelectorAll("input").length > expectedCount, - inputsBeforeClick, - { timeout: 5000 }, - ); - - const filterInput = this.page.locator("input").last(); - - await expect(filterInput).toBeVisible({ timeout: 5000 }); + await this.navigateTo(`${DagRunsPage.dagRunsUrl}?dag_id_pattern=${encodeURIComponent(dagIdPattern)}`); + await this.page.waitForURL(/.*dag_id_pattern=.*/, { timeout: 15_000 }); + await this.page.waitForLoadState("networkidle"); - await filterInput.fill(dagIdPattern); - await filterInput.press("Enter"); + const dataLinks = this.dagRunsTable.locator("a[href*='/dags/']"); - await this.page.waitForURL(/.*dag_id_pattern=.*/, { timeout: 10_000 }); - await this.page.waitForLoadState("networkidle"); + await expect(dataLinks.first()).toBeVisible({ timeout: 30_000 }); await expect(this.dagRunsTable).toBeVisible(); - const rowsAfterFilter = this.dagRunsTable.locator( - 'tbody tr:not(.no-data), div[role="row"]:not(:first-child)', - ); - const countAfter = await rowsAfterFilter.count(); - - // Verify that we have results after filtering - expect(countAfter).toBeGreaterThan(0); + const rows = this.dagRunsTable.locator("tbody tr"); + const rowCount = await rows.count(); - const rows = this.dagRunsTable.locator('tbody tr:not(.no-data), div[role="row"]:not(:first-child)'); - const rowsToCheck = Math.min(countAfter, 5); + expect(rowCount).toBeGreaterThan(0); - for (let i = 0; i < rowsToCheck; i++) { - const row = rows.nth(i); - // Get the first link in the row which should be the DAG ID - const dagIdLink = row.locator("a[href*='/dags/']").first(); + for (let i = 0; i < Math.min(rowCount, 5); i++) { + const dagIdLink = rows.nth(i).locator("a[href*='/dags/']").first(); const dagIdText = await dagIdLink.textContent(); expect(dagIdText).toBeTruthy(); @@ -151,98 +73,77 @@ export class DagRunsPage extends BasePage { } /** - * Verify that DAG runs exist in the table + * Verify that the table contains DAG run data */ public async verifyDagRunsExist(): Promise { - const rows = this.dagRunsTable.locator('tbody tr:not(.no-data), div[role="row"]:not(:first-child)'); + const dataLinks = this.dagRunsTable.locator("a[href*='/dags/']"); - expect(await rows.count()).toBeGreaterThan(0); + await expect(dataLinks.first()).toBeVisible({ timeout: 30_000 }); + expect(await dataLinks.count()).toBeGreaterThan(0); } /** - * Verify pagination works correctly with offset and limit parameters + * Verify pagination controls and navigation */ public async verifyPagination(limit: number): Promise { - // Navigate with limit parameter await this.navigateTo(`${DagRunsPage.dagRunsUrl}?offset=0&limit=${limit}`); - await this.page.waitForURL(/.*dag_runs/, { timeout: 10_000 }); + await this.page.waitForURL(/.*limit=/, { timeout: 10_000 }); + await this.page.waitForLoadState("networkidle"); await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); - const rows = this.dagRunsTable.locator('tbody tr:not(.no-data), div[role="row"]:not(:first-child)'); - const rowCount = await rows.count(); + const dataLinks = this.dagRunsTable.locator("a[href*='/dags/']"); - // Verify we have results - expect(rowCount).toBeGreaterThan(0); + await expect(dataLinks.first()).toBeVisible({ timeout: 30_000 }); - // Verify pagination controls exist - const paginationInfo = this.page.locator("text=/\\d+\\s*-\\s*\\d+\\s*of\\s*\\d+/i"); + const rows = this.dagRunsTable.locator("tbody tr"); - if ((await paginationInfo.count()) > 0) { - await expect(paginationInfo.first()).toBeVisible(); - - const paginationText = (await paginationInfo.first().textContent()) ?? ""; - - expect(paginationText).toBeTruthy(); - - // Extract the total count from pagination text (e.g., "1 - 10 of 50") - const regex = /(\d+)\s*-\s*(\d+)\s*of\s*(\d+)/i; - const match = regex.exec(paginationText); + expect(await rows.count()).toBeGreaterThan(0); - if (match?.[1] !== undefined && match[2] !== undefined && match[3] !== undefined) { - const start = parseInt(match[1], 10); - const end = parseInt(match[2], 10); - const total = parseInt(match[3], 10); + const paginationNav = this.page.locator('nav[aria-label="pagination"], [role="navigation"]'); - expect(start).toBeGreaterThan(0); - expect(end).toBeGreaterThanOrEqual(start); - expect(total).toBeGreaterThanOrEqual(end); + await expect(paginationNav.first()).toBeVisible({ timeout: 10_000 }); - // Test that offset changes when navigating to next page - const initialStart = start; + const page1Button = this.page.getByRole("button", { name: /page 1|^1$/ }); - await this.navigateTo(`${DagRunsPage.dagRunsUrl}?offset=${limit}&limit=${limit}`); - await this.page.waitForURL(/.*dag_runs/, { timeout: 10_000 }); - await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); + await expect(page1Button.first()).toBeVisible({ timeout: 5000 }); - const paginationInfoPage2 = this.page.locator("text=/\\d+\\s*-\\s*\\d+\\s*of\\s*\\d+/i"); + const page2Button = this.page.getByRole("button", { name: /page 2|^2$/ }); + const hasPage2 = await page2Button + .first() + .isVisible() + .catch(() => false); - if ((await paginationInfoPage2.count()) > 0) { - const paginationText2 = (await paginationInfoPage2.first().textContent()) ?? ""; - const regex2 = /(\d+)\s*-\s*(\d+)\s*of\s*(\d+)/i; - const match2 = regex2.exec(paginationText2); + if (hasPage2) { + await page2Button.first().click(); + await this.page.waitForLoadState("networkidle"); + await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); - if (match2?.[1] !== undefined) { - const startPage2 = parseInt(match2[1], 10); + const dataLinksPage2 = this.dagRunsTable.locator("a[href*='/dags/']"); + const noDataMessage = this.page.locator("text=/no.*data|no.*runs|no.*results/i"); - // Verify that the start position changed - expect(startPage2).toBeGreaterThan(initialStart); - } - } - } + await expect(dataLinksPage2.first().or(noDataMessage.first())).toBeVisible({ timeout: 30_000 }); } } /** - * Verify that run details are displayed correctly + * Verify that run details are displayed in the table row */ public async verifyRunDetailsDisplay(): Promise { - const firstRow = this.dagRunsTable.locator("tbody tr, div[role='row']:not(:first-child)").first(); + const firstRow = this.dagRunsTable.locator("tbody tr").first(); + + await expect(firstRow).toBeVisible({ timeout: 10_000 }); const dagIdLink = firstRow.locator("a[href*='/dags/']").first(); - if ((await dagIdLink.count()) > 0) { - await expect(dagIdLink).toBeVisible(); - expect((await dagIdLink.textContent())?.trim()).toBeTruthy(); - } + await expect(dagIdLink).toBeVisible(); + expect((await dagIdLink.textContent())?.trim()).toBeTruthy(); const runIdLink = firstRow.locator("a[href*='/runs/']").first(); await expect(runIdLink).toBeVisible(); expect((await runIdLink.textContent())?.trim()).toBeTruthy(); - const stateCell = firstRow - .locator('td, div[role="cell"]') - .filter({ hasText: /running|success|failed|queued/i }); + const stateCell = firstRow.locator("td").filter({ hasText: /running|success|failed|queued/i }); await expect(stateCell.first()).toBeVisible(); @@ -251,7 +152,7 @@ export class DagRunsPage extends BasePage { if ((await timeElements.count()) > 0) { await expect(timeElements.first()).toBeVisible(); } else { - const cellTexts = await firstRow.locator("td, div[role='cell']").allTextContents(); + const cellTexts = await firstRow.locator("td").allTextContents(); const hasDateFormat = cellTexts.some((text) => /\d{4}(?:-\d{2}){2}|(?:\d{1,2}\/){2}\d{4}|(?:\d{1,2}:){2}\d{2}/.test(text), ); @@ -261,38 +162,27 @@ export class DagRunsPage extends BasePage { } /** - * Verify that state filtering works correctly + * Verify state filtering via URL parameters */ public async verifyStateFiltering(expectedState: string): Promise { - const rowsBeforeFilter = this.dagRunsTable.locator( - 'tbody tr:not(.no-data), div[role="row"]:not(:first-child)', - ); - const countBefore = await rowsBeforeFilter.count(); - - await this.applyFilter("State", expectedState); - - await this.page.waitForURL(/.*state=.*/, { timeout: 10_000 }); + await this.navigateTo(`${DagRunsPage.dagRunsUrl}?state=${expectedState.toLowerCase()}`); + await this.page.waitForURL(/.*state=.*/, { timeout: 15_000 }); await this.page.waitForLoadState("networkidle"); - await expect(this.dagRunsTable).toBeVisible(); - const rowsAfterFilter = this.dagRunsTable.locator( - 'tbody tr:not(.no-data), div[role="row"]:not(:first-child)', - ); - const countAfter = await rowsAfterFilter.count(); + const dataLinks = this.dagRunsTable.locator("a[href*='/dags/']"); - if (countAfter > 0) { - expect(countAfter).toBeLessThanOrEqual(countBefore); + await expect(dataLinks.first()).toBeVisible({ timeout: 30_000 }); + await expect(this.dagRunsTable).toBeVisible(); - const stateCells = this.dagRunsTable - .locator('[class*="badge"], [class*="Badge"]') - .filter({ hasText: new RegExp(expectedState, "i") }); + const rows = this.dagRunsTable.locator("tbody tr"); + const rowCount = await rows.count(); - await expect(stateCells.first()).toBeVisible({ timeout: 5000 }); + expect(rowCount).toBeGreaterThan(0); - const badgeCount = await stateCells.count(); + for (let i = 0; i < rowCount; i++) { + const rowText = await rows.nth(i).textContent(); - expect(badgeCount).toBeGreaterThan(0); - expect(badgeCount).toBeLessThanOrEqual(countAfter); + expect(rowText?.toLowerCase()).toContain(expectedState.toLowerCase()); } } } diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts index 3d61b0fe6a0c1..718f00b7cd0d6 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts @@ -26,8 +26,6 @@ test.describe("DAG Runs Page", () => { let dagRunsPage: DagRunsPage; const testDagId1 = testConfig.testDag.id; const testDagId2 = "example_python_operator"; - let failedRunId: string; - let successRunId: string; test.beforeAll(async ({ browser }) => { const context = await browser.newContext({ storageState: AUTH_FILE }); @@ -36,7 +34,7 @@ test.describe("DAG Runs Page", () => { const timestamp = Date.now(); - // Trigger first DAG run + // Trigger first DAG run and mark as failed const runId1 = `test_run_failed_${timestamp}`; const logicalDate1 = new Date(timestamp).toISOString(); const triggerResponse1 = await page.request.post(`${baseUrl}/api/v2/dags/${testDagId1}/dagRuns`, { @@ -52,11 +50,9 @@ test.describe("DAG Runs Page", () => { expect(triggerResponse1.ok()).toBeTruthy(); const runData1 = (await triggerResponse1.json()) as { dag_run_id: string }; - failedRunId = runData1.dag_run_id; - // Mark the first run as failed - const patchResponse = await page.request.patch( - `${baseUrl}/api/v2/dags/${testDagId1}/dagRuns/${failedRunId}`, + const patchResponse1 = await page.request.patch( + `${baseUrl}/api/v2/dags/${testDagId1}/dagRuns/${runData1.dag_run_id}`, { data: JSON.stringify({ state: "failed" }), headers: { @@ -65,9 +61,9 @@ test.describe("DAG Runs Page", () => { }, ); - expect(patchResponse.ok()).toBeTruthy(); + expect(patchResponse1.ok()).toBeTruthy(); - // Trigger second DAG run for success state + // Trigger second DAG run and mark as success const runId2 = `test_run_success_${timestamp}`; const logicalDate2 = new Date(timestamp + 60_000).toISOString(); const triggerResponse2 = await page.request.post(`${baseUrl}/api/v2/dags/${testDagId1}/dagRuns`, { @@ -83,11 +79,9 @@ test.describe("DAG Runs Page", () => { expect(triggerResponse2.ok()).toBeTruthy(); const runData2 = (await triggerResponse2.json()) as { dag_run_id: string }; - successRunId = runData2.dag_run_id; - // Mark the second run as success const patchResponse2 = await page.request.patch( - `${baseUrl}/api/v2/dags/${testDagId1}/dagRuns/${successRunId}`, + `${baseUrl}/api/v2/dags/${testDagId1}/dagRuns/${runData2.dag_run_id}`, { data: JSON.stringify({ state: "success" }), headers: { @@ -98,11 +92,11 @@ test.describe("DAG Runs Page", () => { expect(patchResponse2.ok()).toBeTruthy(); - // Trigger a run for a different DAG + // Trigger a run for a different DAG (for DAG ID filtering test) const runId3 = `test_run_other_dag_${timestamp}`; const logicalDate3 = new Date(timestamp + 120_000).toISOString(); - await page.request.post(`${baseUrl}/api/v2/dags/${testDagId2}/dagRuns`, { + const triggerResponse3 = await page.request.post(`${baseUrl}/api/v2/dags/${testDagId2}/dagRuns`, { data: JSON.stringify({ dag_run_id: runId3, logical_date: logicalDate3, @@ -112,6 +106,8 @@ test.describe("DAG Runs Page", () => { }, }); + expect(triggerResponse3.ok()).toBeTruthy(); + await context.close(); }); @@ -119,32 +115,32 @@ test.describe("DAG Runs Page", () => { dagRunsPage = new DagRunsPage(page); }); - test("should display the DAG runs table with data", async () => { + test("verify DAG runs table displays data", async () => { await dagRunsPage.navigate(); await dagRunsPage.verifyDagRunsExist(); }); - test("should display run details: run ID, state, and dates", async () => { + test("verify run details display correctly", async () => { await dagRunsPage.navigate(); await dagRunsPage.verifyRunDetailsDisplay(); }); - test("should filter by failed state correctly", async () => { + test("verify filtering by failed state", async () => { await dagRunsPage.navigate(); await dagRunsPage.verifyStateFiltering("Failed"); }); - test("should filter by success state correctly", async () => { + test("verify filtering by success state", async () => { await dagRunsPage.navigate(); await dagRunsPage.verifyStateFiltering("Success"); }); - test("should filter by DAG ID correctly", async () => { + test("verify filtering by DAG ID", async () => { await dagRunsPage.navigate(); await dagRunsPage.verifyDagIdFiltering(testDagId1); }); - test("should support pagination with offset and limit", async () => { + test("verify pagination with offset and limit", async () => { await dagRunsPage.verifyPagination(3); }); });