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);
+  });
+});

Reply via email to