This is an automated email from the ASF dual-hosted git repository.
rahulvats pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 16220ddd170 Feat : E2E test to Verify backfills list displays (#59791)
16220ddd170 is described below
commit 16220ddd17094705b260c5d2251dfc346794b5be
Author: Harsh Thakur <[email protected]>
AuthorDate: Tue Dec 30 10:04:01 2025 +0530
Feat : E2E test to Verify backfills list displays (#59791)
Feat : E2E test to Verify backfills list displays
---
.../src/airflow/ui/src/components/StateBadge.tsx | 1 +
.../src/airflow/ui/tests/e2e/pages/BackfillPage.ts | 224 +++++++++++++++++++++
.../airflow/ui/tests/e2e/specs/backfill.spec.ts | 136 +++++++++++++
3 files changed, 361 insertions(+)
diff --git a/airflow-core/src/airflow/ui/src/components/StateBadge.tsx
b/airflow-core/src/airflow/ui/src/components/StateBadge.tsx
index f5a07f3884a..ca2784fa7f4 100644
--- a/airflow-core/src/airflow/ui/src/components/StateBadge.tsx
+++ b/airflow-core/src/airflow/ui/src/components/StateBadge.tsx
@@ -31,6 +31,7 @@ export const StateBadge = React.forwardRef<HTMLDivElement,
Props>(({ children, s
<Badge
borderRadius="full"
colorPalette={state === null ? "none" : state}
+ data-testid="state-badge"
fontSize="sm"
px={children === undefined ? 1 : 2}
py={1}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
new file mode 100644
index 00000000000..25ec7250088
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
@@ -0,0 +1,224 @@
+/*!
+ * 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, Page } from "@playwright/test";
+import { BasePage } from "tests/e2e/pages/BasePage";
+
+export type ReprocessBehavior = "All Runs" | "Missing and Errored Runs" |
"Missing Runs";
+
+export type CreateBackfillOptions = {
+ fromDate: string;
+ reprocessBehavior: ReprocessBehavior;
+ toDate: string;
+};
+
+export type VerifyBackfillOptions = {
+ dagName: string;
+ expectedFromDate: string;
+ expectedToDate: string;
+ reprocessBehavior: ReprocessBehavior;
+};
+
+export class BackfillPage extends BasePage {
+ public readonly backfillDateError: Locator;
+ public readonly backfillFromDateInput: Locator;
+ public readonly backfillModeRadio: Locator;
+ public readonly backfillRunButton: Locator;
+ public readonly backfillsTable: Locator;
+ public readonly backfillToDateInput: Locator;
+ public readonly triggerButton: Locator;
+
+ public constructor(page: Page) {
+ super(page);
+ this.triggerButton = page.locator('button[aria-label="Trigger
Dag"]:has-text("Trigger")');
+ this.backfillModeRadio = page.locator('label:has-text("Backfill")');
+ this.backfillFromDateInput =
page.locator('input[type="datetime-local"]').first();
+ this.backfillToDateInput =
page.locator('input[type="datetime-local"]').nth(1);
+ this.backfillRunButton = page.locator('button:has-text("Run Backfill")');
+ this.backfillsTable = page.locator("table");
+ this.backfillDateError = page.locator('text="Start Date must be before the
End Date"');
+ }
+
+ public static getBackfillsUrl(dagName: string): string {
+ return `/dags/${dagName}/backfills`;
+ }
+
+ public static getDagDetailUrl(dagName: string): string {
+ return `/dags/${dagName}`;
+ }
+
+ public async createBackfill(dagName: string, options:
CreateBackfillOptions): Promise<void> {
+ const { fromDate, reprocessBehavior, toDate } = options;
+
+ await this.navigateToDagDetail(dagName);
+ await this.openBackfillDialog();
+
+ await this.backfillFromDateInput.fill(fromDate);
+ await this.backfillToDateInput.fill(toDate);
+
+ await this.selectReprocessBehavior(reprocessBehavior);
+
+ const runsMessage = this.page.locator("text=/\\d+ runs? will be
triggered|No runs matching/");
+
+ await expect(runsMessage).toBeVisible({ timeout: 10_000 });
+
+ await expect(this.backfillRunButton).toBeEnabled({ timeout: 5000 });
+ await this.backfillRunButton.click();
+ }
+
+ // Get backfill details
+ public async getBackfillDetails(rowIndex: number = 0): Promise<{
+ createdAt: string;
+ fromDate: string;
+ reprocessBehavior: string;
+ toDate: string;
+ }> {
+ // Get the row data
+ const row = this.page.locator("table tbody tr").nth(rowIndex);
+ const cells = row.locator("td");
+
+ await expect(row).toBeVisible({ timeout: 10_000 });
+
+ // Get column headers to map column names to indices
+ const headers = this.page.locator("table thead th");
+ const headerTexts = await headers.allTextContents();
+ const columnMap = new Map<string, number>(headerTexts.map((text, index) =>
[text.trim(), index]));
+
+ // Extract data using column headers
+ const fromDateIndex = columnMap.get("From") ?? 0;
+ const toDateIndex = columnMap.get("To") ?? 1;
+ const reprocessBehaviorIndex = columnMap.get("Reprocess Behavior") ?? 2;
+ const createdAtIndex = columnMap.get("Created at") ?? 3;
+
+ await expect(row.first()).not.toBeEmpty();
+
+ const fromDate = (await cells.nth(fromDateIndex).textContent()) ?? "";
+ const toDate = (await cells.nth(toDateIndex).textContent()) ?? "";
+ const reprocessBehavior = (await
cells.nth(reprocessBehaviorIndex).textContent()) ?? "";
+ const createdAt = (await cells.nth(createdAtIndex).textContent()) ?? "";
+
+ return {
+ createdAt: createdAt.trim(),
+ fromDate: fromDate.trim(),
+ reprocessBehavior: reprocessBehavior.trim(),
+ toDate: toDate.trim(),
+ };
+ }
+
+ public async getBackfillsTableRows(): Promise<number> {
+ const rows = this.page.locator("table tbody tr");
+
+ await rows.first().waitFor({ state: "visible", timeout: 10_000 });
+ const count = await rows.count();
+
+ return count;
+ }
+
+ // Get backfill status
+ public async getBackfillStatus(): Promise<string> {
+ const statusIcon = this.page.getByTestId("state-badge").first();
+
+ await expect(statusIcon).toBeVisible();
+ await statusIcon.click();
+
+ await this.page.waitForLoadState("networkidle");
+
+ const statusBadge = this.page.getByTestId("state-badge").first();
+
+ await expect(statusBadge).toBeVisible();
+
+ const statusText = (await statusBadge.textContent()) ?? "";
+
+ return statusText.trim();
+ }
+
+ // Get column header locator for assertions
+ public getColumnHeader(columnName: string): Locator {
+ return this.page.locator(`th:has-text("${columnName}")`);
+ }
+
+ // Get filter button
+ public getFilterButton(): Locator {
+ return this.page.locator('button[aria-label*="filter"],
button[aria-label*="Filter"]');
+ }
+
+ // Get number of table columns
+ public async getTableColumnCount(): Promise<number> {
+ const headers = this.page.locator("table thead th");
+
+ return await headers.count();
+ }
+
+ public async isBackfillDateErrorVisible(): Promise<boolean> {
+ return this.backfillDateError.isVisible();
+ }
+
+ public async navigateToBackfillsTab(dagName: string): Promise<void> {
+ await this.navigateTo(BackfillPage.getBackfillsUrl(dagName));
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ public async navigateToDagDetail(dagName: string): Promise<void> {
+ await this.navigateTo(BackfillPage.getDagDetailUrl(dagName));
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ public async openBackfillDialog(): Promise<void> {
+ await this.triggerButton.waitFor({ state: "visible", timeout: 10_000 });
+ await this.triggerButton.click();
+
+ await expect(this.backfillModeRadio).toBeVisible({ timeout: 8000 });
+ await this.backfillModeRadio.click();
+
+ await expect(this.backfillFromDateInput).toBeVisible({ timeout: 5000 });
+ }
+
+ // Open the filter menu
+ public async openFilterMenu(): Promise<void> {
+ const filterButton = this.getFilterButton();
+
+ await filterButton.click();
+
+ // Wait for menu to appear
+ const filterMenu = this.page.locator('[role="menu"]');
+
+ await filterMenu.waitFor({ state: "visible", timeout: 5000 });
+ }
+
+ public async selectReprocessBehavior(behavior: ReprocessBehavior):
Promise<void> {
+ const behaviorLabels: Record<ReprocessBehavior, string> = {
+ "All Runs": "All Runs",
+ "Missing and Errored Runs": "Missing and Errored Runs",
+ "Missing Runs": "Missing Runs",
+ };
+
+ const label = behaviorLabels[behavior];
+ const radioItem = this.page.locator(`label:has-text("${label}")`).first();
+
+ await radioItem.waitFor({ state: "visible", timeout: 5000 });
+ await radioItem.click();
+ }
+
+ // Toggle a column's visibility in the filter menu
+ public async toggleColumn(columnName: string): Promise<void> {
+ const menuItem =
this.page.locator(`[role="menuitem"]:has-text("${columnName}")`);
+
+ await menuItem.click();
+ }
+}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
new file mode 100644
index 00000000000..2fb8bd302e2
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
@@ -0,0 +1,136 @@
+/*!
+ * 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 { testConfig } from "playwright.config";
+import { BackfillPage } from "tests/e2e/pages/BackfillPage";
+
+const getPastDate = (daysAgo: number): string => {
+ const date = new Date();
+
+ date.setDate(date.getDate() - daysAgo);
+ date.setHours(0, 0, 0, 0);
+
+ return date.toISOString().slice(0, 16);
+};
+
+// Backfills E2E Tests
+
+test.describe("Backfills List Display", () => {
+ let backfillPage: BackfillPage;
+ const testDagId = testConfig.testDag.id;
+ let createdFromDate: string;
+ let createdToDate: string;
+
+ test.beforeAll(async ({ browser }) => {
+ test.setTimeout(60_000);
+
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ backfillPage = new BackfillPage(page);
+
+ createdFromDate = getPastDate(2);
+ createdToDate = getPastDate(1);
+
+ await backfillPage.createBackfill(testDagId, {
+ fromDate: createdFromDate,
+ reprocessBehavior: "All Runs",
+ toDate: createdToDate,
+ });
+
+ await backfillPage.navigateToBackfillsTab(testDagId);
+ });
+
+ test("should verify backfills list display", async () => {
+ const rowsCount = await backfillPage.getBackfillsTableRows();
+
+ await expect(backfillPage.backfillsTable).toBeVisible();
+ expect(rowsCount).toBeGreaterThanOrEqual(1);
+ });
+
+ test("Verify backfill details display: date range, status, created time",
async () => {
+ const backfillDetails = await backfillPage.getBackfillDetails(0);
+
+ // validate date range
+ expect(backfillDetails.fromDate.slice(0,
10)).toEqual(createdFromDate.slice(0, 10));
+ expect(backfillDetails.toDate.slice(0, 10)).toEqual(createdToDate.slice(0,
10));
+
+ // Validate backfill status
+ const status = await backfillPage.getBackfillStatus();
+
+ expect(status).not.toEqual("");
+
+ // Validate created time
+ expect(backfillDetails.createdAt).not.toEqual("");
+ });
+
+ test("should verify Table filters", async () => {
+ await backfillPage.navigateToBackfillsTab(testDagId);
+
+ const initialColumnCount = await backfillPage.getTableColumnCount();
+
+ expect(initialColumnCount).toBeGreaterThan(0);
+
+ // Verify filter button is available
+ await expect(backfillPage.getFilterButton()).toBeVisible();
+
+ // Open filter menu to see available columns
+ await backfillPage.openFilterMenu();
+
+ // Get all filterable columns (those with checkboxes in the menu)
+ const filterMenuItems = backfillPage.page.locator('[role="menuitem"]');
+ const filterMenuCount = await filterMenuItems.count();
+
+ // Ensure there are filterable columns
+ expect(filterMenuCount).toBeGreaterThan(0);
+
+ // Get the first filterable column that has a checkbox
+ const firstMenuItem = filterMenuItems.first();
+ const columnToToggle = (await firstMenuItem.textContent())?.trim() ?? "";
+
+ expect(columnToToggle).not.toBe("");
+
+ // Toggle column off
+ await backfillPage.toggleColumn(columnToToggle);
+
+ await backfillPage.page.keyboard.press("Escape");
+ await backfillPage.page.locator('[role="menu"]').waitFor({ state: "hidden"
});
+
+ // Verify column is hidden
+ await
expect(backfillPage.getColumnHeader(columnToToggle)).not.toBeVisible();
+
+ const newColumnCount = await backfillPage.getTableColumnCount();
+
+ expect(newColumnCount).toBeLessThan(initialColumnCount);
+
+ // Toggle column back on
+ await backfillPage.openFilterMenu();
+ await backfillPage.toggleColumn(columnToToggle);
+
+ await backfillPage.page.keyboard.press("Escape");
+ await backfillPage.page.locator('[role="menu"]').waitFor({ state: "hidden"
});
+
+ // Verify column is visible again
+ await expect(backfillPage.getColumnHeader(columnToToggle)).toBeVisible();
+
+ const finalColumnCount = await backfillPage.getTableColumnCount();
+
+ expect(finalColumnCount).toBe(initialColumnCount);
+ });
+});