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 2ca87da0b8a Add E2E tests to verify DAG runs tab functionality #59541 
(#61234)
2ca87da0b8a is described below

commit 2ca87da0b8a34a422af91643dfe600c332935ba0
Author: Haseeb Malik <[email protected]>
AuthorDate: Wed Feb 11 10:04:52 2026 -0500

    Add E2E tests to verify DAG runs tab functionality #59541 (#61234)
---
 .../src/components/MarkAs/Run/MarkRunAsButton.tsx  |   1 +
 .../src/components/TriggerDag/TriggerDAGButton.tsx |   3 +
 .../airflow/ui/tests/e2e/pages/DagRunsTabPage.ts   | 294 +++++++++++++++++++++
 .../ui/tests/e2e/specs/dag-runs-tab.spec.ts        | 123 +++++++++
 4 files changed, 421 insertions(+)

diff --git 
a/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx 
b/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
index a9fc90799d3..a71fc8c2821 100644
--- a/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
@@ -71,6 +71,7 @@ const MarkRunAsButton = ({ dagRun, isHotkeyEnabled = false }: 
Props) => {
                   type: translate("dagRun_one"),
                 })}
                 colorPalette="brand"
+                data-testid="mark-run-as-button"
                 size="md"
                 variant="ghost"
               >
diff --git 
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
index d27169a8a9b..83308ca4a26 100644
--- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
@@ -95,6 +95,7 @@ export const TriggerDAGButton = ({
             <Button
               aria-label={translate("triggerDag.title")}
               colorPalette="brand"
+              data-testid="trigger-dag-button"
               size="md"
               variant={variant}
             >
@@ -132,6 +133,7 @@ export const TriggerDAGButton = ({
           <Button
             aria-label={translate("triggerDag.title")}
             colorPalette="brand"
+            data-testid="trigger-dag-button"
             onClick={handleNormalTrigger}
             size="md"
             variant={variant}
@@ -143,6 +145,7 @@ export const TriggerDAGButton = ({
           <IconButton
             aria-label={translate("triggerDag.title")}
             colorPalette="brand"
+            data-testid="trigger-dag-button"
             onClick={onOpen}
             size="md"
             variant={variant}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsTabPage.ts 
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsTabPage.ts
new file mode 100644
index 00000000000..1e5a3e0c943
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsTabPage.ts
@@ -0,0 +1,294 @@
+/*!
+ * 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";
+
+export class DagRunsTabPage extends BasePage {
+  public readonly markRunAsButton: Locator;
+  public readonly nextPageButton: Locator;
+  public readonly prevPageButton: Locator;
+  public readonly runsTable: Locator;
+  public readonly tableRows: Locator;
+  public readonly triggerButton: Locator;
+
+  private currentDagId?: string;
+  private currentLimit?: number;
+
+  public constructor(page: Page) {
+    super(page);
+    this.markRunAsButton = 
page.locator('[data-testid="mark-run-as-button"]').first();
+    this.nextPageButton = page.locator('[data-testid="next"]');
+    this.prevPageButton = page.locator('[data-testid="prev"]');
+    this.runsTable = page.locator('[data-testid="table-list"]');
+    this.tableRows = this.runsTable.locator("tbody tr");
+    this.triggerButton = page.locator('[data-testid="trigger-dag-button"]');
+  }
+
+  public async clickNextPage(): Promise<void> {
+    await this.waitForRunsTableToLoad();
+    const firstRunLink = 
this.tableRows.first().locator("a[href*='/runs/']").first();
+
+    await expect(firstRunLink).toBeVisible();
+    const firstRunId = await firstRunLink.textContent();
+
+    if (firstRunId === null || firstRunId === "") {
+      throw new Error("Could not get first run ID before pagination");
+    }
+
+    await this.nextPageButton.click();
+    await expect(this.tableRows.first()).not.toContainText(firstRunId, { 
timeout: 10_000 });
+    await this.ensureUrlParams();
+  }
+
+  public async clickPrevPage(): Promise<void> {
+    await this.waitForRunsTableToLoad();
+    const firstRunLink = 
this.tableRows.first().locator("a[href*='/runs/']").first();
+
+    await expect(firstRunLink).toBeVisible();
+    const firstRunId = await firstRunLink.textContent();
+
+    if (firstRunId === null || firstRunId === "") {
+      throw new Error("Could not get first run ID before pagination");
+    }
+
+    await this.prevPageButton.click();
+    await expect(this.tableRows.first()).not.toContainText(firstRunId, { 
timeout: 10_000 });
+    await this.ensureUrlParams();
+  }
+
+  public async clickRunAndVerifyDetails(): Promise<void> {
+    const firstRunLink = 
this.tableRows.first().locator("a[href*='/runs/']").first();
+
+    await expect(firstRunLink).toBeVisible({ timeout: 10_000 });
+    await firstRunLink.click();
+    await this.page.waitForURL(/.*\/dags\/.*\/runs\/[^/]+$/, { timeout: 15_000 
});
+    await expect(this.markRunAsButton).toBeVisible({ timeout: 10_000 });
+  }
+
+  public async clickRunsTab(): Promise<void> {
+    const runsTab = this.page.locator('a[href$="/runs"]');
+
+    await expect(runsTab).toBeVisible({ timeout: 10_000 });
+    await runsTab.click();
+    await this.page.waitForURL(/.*\/dags\/[^/]+\/runs/, { timeout: 15_000 });
+    await this.waitForRunsTableToLoad();
+  }
+
+  public async clickRunsTabWithPageSize(dagId: string, pageSize: number): 
Promise<void> {
+    this.currentDagId = dagId;
+    this.currentLimit = pageSize;
+
+    await this.navigateTo(`/dags/${dagId}/runs?offset=0&limit=${pageSize}`);
+    await this.page.waitForURL(/.*\/dags\/[^/]+\/runs.*offset=0&limit=/, {
+      timeout: 15_000,
+    });
+    await this.waitForRunsTableToLoad();
+  }
+
+  public async filterByState(state: string): Promise<void> {
+    const currentUrl = new URL(this.page.url());
+
+    currentUrl.searchParams.set("state", state.toLowerCase());
+    await this.navigateTo(currentUrl.pathname + currentUrl.search);
+    await this.page.waitForURL(/.*state=.*/, { timeout: 15_000 });
+    await this.waitForRunsTableToLoad();
+  }
+
+  public async getRowCount(): Promise<number> {
+    await this.waitForRunsTableToLoad();
+
+    return this.tableRows.count();
+  }
+
+  public async markRunAs(state: "failed" | "success"): Promise<void> {
+    const stateBadge = 
this.page.locator('[data-testid="state-badge"]').first();
+
+    await expect(stateBadge).toBeVisible({ timeout: 10_000 });
+    const currentState = await stateBadge.textContent();
+
+    if (currentState?.toLowerCase().includes(state)) {
+      return;
+    }
+
+    await expect(this.markRunAsButton).toBeVisible({ timeout: 10_000 });
+    await this.markRunAsButton.click();
+
+    const stateOption = 
this.page.locator(`[data-testid="mark-run-as-${state}"]`);
+
+    await expect(stateOption).toBeVisible({ timeout: 5000 });
+    await stateOption.click();
+
+    const confirmButton = this.page.getByRole("button", { name: "Confirm" });
+
+    await expect(confirmButton).toBeVisible({ timeout: 5000 });
+
+    const responsePromise = this.page.waitForResponse(
+      (response) => response.url().includes("dagRuns") && 
response.request().method() === "PATCH",
+      { timeout: 10_000 },
+    );
+
+    await confirmButton.click();
+    await responsePromise;
+
+    await expect(confirmButton).toBeHidden({ timeout: 10_000 });
+  }
+
+  public async navigateToDag(dagId: string): Promise<void> {
+    await this.navigateTo(`/dags/${dagId}`);
+    await this.page.waitForURL(`**/dags/${dagId}**`, { timeout: 15_000 });
+    await expect(this.triggerButton).toBeVisible({ timeout: 10_000 });
+  }
+
+  public async navigateToRunDetails(dagId: string, runId: string): 
Promise<void> {
+    await this.navigateTo(`/dags/${dagId}/runs/${runId}`);
+    await this.page.waitForURL(`**/dags/${dagId}/runs/${runId}**`, { timeout: 
15_000 });
+    await expect(this.markRunAsButton).toBeVisible({ timeout: 15_000 });
+  }
+
+  public async searchByRunIdPattern(pattern: string): Promise<void> {
+    const currentUrl = new URL(this.page.url());
+
+    currentUrl.searchParams.set("run_id_pattern", pattern);
+    await this.navigateTo(currentUrl.pathname + currentUrl.search);
+    await this.page.waitForURL(/.*run_id_pattern=.*/, { timeout: 15_000 });
+    await this.waitForRunsTableToLoad();
+  }
+
+  public async triggerDagRun(): Promise<string | undefined> {
+    await expect(this.triggerButton).toBeVisible({ timeout: 10_000 });
+    await this.triggerButton.click();
+
+    const dialog = this.page.getByRole("dialog");
+
+    await expect(dialog).toBeVisible({ timeout: 8000 });
+
+    const confirmButton = dialog.getByRole("button", { name: "Trigger" });
+
+    await expect(confirmButton).toBeVisible({ timeout: 5000 });
+
+    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: 15_000 },
+    );
+
+    await confirmButton.click();
+
+    const apiResponse = await responsePromise;
+
+    const responseBody = await apiResponse.text();
+    const responseJson = JSON.parse(responseBody) as { dag_run_id?: string };
+
+    return responseJson.dag_run_id;
+  }
+
+  public async verifyFilteredByState(expectedState: string): Promise<void> {
+    await this.waitForRunsTableToLoad();
+
+    const rows = this.tableRows;
+
+    await expect(rows).not.toHaveCount(0);
+
+    const rowCount = await rows.count();
+
+    for (let i = 0; i < Math.min(rowCount, 5); i++) {
+      const stateBadge = rows.nth(i).locator('[data-testid="state-badge"]');
+
+      await expect(stateBadge).toBeVisible();
+      await expect(stateBadge).toContainText(expectedState, { ignoreCase: true 
});
+    }
+  }
+
+  public async verifyRunDetailsDisplay(): Promise<void> {
+    const firstRow = this.tableRows.first();
+
+    await expect(firstRow).toBeVisible({ timeout: 10_000 });
+
+    const runIdLink = firstRow.locator("a[href*='/runs/']").first();
+
+    await expect(runIdLink).toBeVisible();
+    await expect(runIdLink).not.toBeEmpty();
+
+    const stateBadge = firstRow.locator('[data-testid="state-badge"]');
+
+    await expect(stateBadge).toBeVisible();
+
+    const timeElements = firstRow.locator("time");
+
+    await expect(timeElements.first()).toBeVisible();
+  }
+
+  public async verifyRunsExist(): Promise<void> {
+    const runLinks = this.runsTable.locator("a[href*='/runs/']");
+
+    await expect(runLinks.first()).toBeVisible({ timeout: 30_000 });
+    await expect(runLinks).not.toHaveCount(0);
+  }
+
+  public async verifySearchResults(pattern: string): Promise<void> {
+    await this.waitForRunsTableToLoad();
+
+    const rows = this.tableRows;
+
+    await expect(rows).not.toHaveCount(0);
+
+    const count = await rows.count();
+
+    for (let i = 0; i < Math.min(count, 5); i++) {
+      const runIdLink = rows.nth(i).locator("a[href*='/runs/']").first();
+
+      await expect(runIdLink).toContainText(pattern, { ignoreCase: true });
+    }
+  }
+
+  public async waitForRunsTableToLoad(): Promise<void> {
+    await expect(this.runsTable).toBeVisible({ timeout: 10_000 });
+
+    const dataLink = this.runsTable.locator("a[href*='/runs/']").first();
+    const noDataMessage = this.page.getByText(/no.*dag.*runs.*found/i);
+
+    await expect(dataLink.or(noDataMessage)).toBeVisible({ timeout: 30_000 });
+  }
+
+  private async ensureUrlParams(): Promise<void> {
+    if (this.currentLimit === undefined || this.currentDagId === undefined) {
+      return;
+    }
+
+    const currentUrl = this.page.url();
+    const url = new URL(currentUrl);
+    const hasLimit = url.searchParams.has("limit");
+    const hasOffset = url.searchParams.has("offset");
+
+    if (hasLimit && !hasOffset) {
+      url.searchParams.set("offset", "0");
+      await this.navigateTo(url.pathname + url.search);
+      await this.waitForRunsTableToLoad();
+    } else if (!hasLimit && !hasOffset) {
+      url.searchParams.set("offset", "0");
+      url.searchParams.set("limit", String(this.currentLimit));
+      await this.navigateTo(url.pathname + url.search);
+      await this.waitForRunsTableToLoad();
+    }
+  }
+}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs-tab.spec.ts 
b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs-tab.spec.ts
new file mode 100644
index 00000000000..14dcd6e3e2a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs-tab.spec.ts
@@ -0,0 +1,123 @@
+/*!
+ * 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, test } from "@playwright/test";
+import { AUTH_FILE, testConfig } from "playwright.config";
+import { DagRunsTabPage } from "tests/e2e/pages/DagRunsTabPage";
+
+test.describe("DAG Runs Tab", () => {
+  test.setTimeout(60_000);
+
+  let dagRunsTabPage: DagRunsTabPage;
+  const testDagId = testConfig.testDag.id;
+
+  test.beforeAll(async ({ browser }) => {
+    test.setTimeout(3 * 60 * 1000);
+    const context = await browser.newContext({ storageState: AUTH_FILE });
+    const page = await context.newPage();
+    const setupPage = new DagRunsTabPage(page);
+
+    await setupPage.navigateToDag(testDagId);
+    const runId1 = await setupPage.triggerDagRun();
+
+    if (runId1 !== undefined) {
+      await setupPage.navigateToRunDetails(testDagId, runId1);
+      await setupPage.markRunAs("success");
+    }
+
+    await setupPage.navigateToDag(testDagId);
+    const runId2 = await setupPage.triggerDagRun();
+
+    if (runId2 !== undefined) {
+      await setupPage.navigateToRunDetails(testDagId, runId2);
+      await setupPage.markRunAs("failed");
+    }
+
+    await context.close();
+  });
+
+  test.beforeEach(({ page }) => {
+    dagRunsTabPage = new DagRunsTabPage(page);
+  });
+
+  test("navigate to DAG detail page and click Runs tab", async () => {
+    await dagRunsTabPage.navigateToDag(testDagId);
+    await dagRunsTabPage.clickRunsTab();
+
+    await expect(dagRunsTabPage.page).toHaveURL(/\/dags\/.*\/runs/);
+  });
+
+  test("verify run details display correctly", async () => {
+    await dagRunsTabPage.navigateToDag(testDagId);
+    await dagRunsTabPage.clickRunsTab();
+    await dagRunsTabPage.verifyRunDetailsDisplay();
+  });
+
+  test("verify runs exist in table", async () => {
+    await dagRunsTabPage.navigateToDag(testDagId);
+    await dagRunsTabPage.clickRunsTab();
+    await dagRunsTabPage.verifyRunsExist();
+  });
+
+  test("click on a run and verify run details page", async () => {
+    await dagRunsTabPage.navigateToDag(testDagId);
+    await dagRunsTabPage.clickRunsTab();
+    await dagRunsTabPage.clickRunAndVerifyDetails();
+  });
+
+  test("filter runs by success state", async () => {
+    await dagRunsTabPage.navigateToDag(testDagId);
+    await dagRunsTabPage.clickRunsTab();
+    await dagRunsTabPage.filterByState("success");
+    await dagRunsTabPage.verifyFilteredByState("success");
+  });
+
+  test("filter runs by failed state", async () => {
+    await dagRunsTabPage.navigateToDag(testDagId);
+    await dagRunsTabPage.clickRunsTab();
+    await dagRunsTabPage.filterByState("failed");
+    await dagRunsTabPage.verifyFilteredByState("failed");
+  });
+
+  test("search for dag run by run ID pattern", async () => {
+    await dagRunsTabPage.navigateToDag(testDagId);
+    await dagRunsTabPage.clickRunsTab();
+    await dagRunsTabPage.searchByRunIdPattern("manual");
+    await dagRunsTabPage.verifySearchResults("manual");
+  });
+
+  test("paginate through runs", async () => {
+    await dagRunsTabPage.clickRunsTabWithPageSize(testDagId, 2);
+
+    const initialRowCount = await dagRunsTabPage.getRowCount();
+
+    expect(initialRowCount).toBe(2);
+
+    await dagRunsTabPage.clickNextPage();
+
+    const nextPageRowCount = await dagRunsTabPage.getRowCount();
+
+    expect(nextPageRowCount).toBeGreaterThan(0);
+
+    await dagRunsTabPage.clickPrevPage();
+
+    const backRowCount = await dagRunsTabPage.getRowCount();
+
+    expect(backRowCount).toBe(initialRowCount);
+  });
+});

Reply via email to