This is an automated email from the ASF dual-hosted git repository.

choo121600 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 2337d802108 feat: Replace Playwright anti-patterns with web-first 
assertions in dag-list (#63559)
2337d802108 is described below

commit 2337d802108ab97347fc8606ccaa5c81b9e18841
Author: BitToby <[email protected]>
AuthorDate: Thu Mar 19 14:24:38 2026 +0200

    feat: Replace Playwright anti-patterns with web-first assertions in 
dag-list (#63559)
    
    * E2E: Replace Playwright anti-patterns with web-first assertions in 
dags-list
    
    * fix: solve PROD Image test error
    
    * fix: Fix remaining Playwright anti-patterns in DagsPage
    
    * fix: fix lint error
    
    * fix: Update stale breeze command output hash
    
    * fix: Use getByRole for card/table view toggle buttons
    
    * fix: Replace fragile state locator with data-testid and remove redundant 
.catch() wrappers
    
    * fix: update
    
    * fix: use data-testid and data-state assertions
    
    * fix: solve testing error
    
    * update
    
    * update
    
    * fix hook format
---
 .../src/airflow/ui/src/pages/Run/Details.tsx       |   2 +-
 .../src/airflow/ui/tests/e2e/pages/DagsPage.ts     | 249 ++++++++++-----------
 .../airflow/ui/tests/e2e/specs/dags-list.spec.ts   |  35 +--
 3 files changed, 125 insertions(+), 161 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
index 7d18930afe5..da23bd4b095 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
@@ -53,7 +53,7 @@ export const Details = () => {
       <Table.Body>
         <Table.Row>
           <Table.Cell>{translate("state")}</Table.Cell>
-          <Table.Cell>
+          <Table.Cell data-testid="dag-run-state">
             <Flex gap={1}>
               <StateBadge state={dagRun.state} />
               {translate(`common:states.${dagRun.state}`)}
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 5f510b797b1..c0121ecc958 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { expect, type Locator, type Page } from "@playwright/test";
+import { expect, type Locator, type Page, type Response } from 
"@playwright/test";
 import { BasePage } from "tests/e2e/pages/BasePage";
 
 import type { DAGRunResponse } from "openapi/requests/types.gen";
@@ -52,26 +52,25 @@ export class DagsPage extends BasePage {
 
   public constructor(page: Page) {
     super(page);
-    this.triggerButton = page.locator('button[aria-label="Trigger 
Dag"]:has-text("Trigger")');
-    // Use .last() instead of .nth(1) — when the modal opens, the confirm 
button
-    // is the last "Trigger" button in the DOM regardless of whether the main
-    // page trigger button has visible text or is icon-only.
-    this.confirmButton = page.locator('button:has-text("Trigger")').last();
-    this.stateElement = page.locator('*:has-text("State") + *').first();
+    this.triggerButton = page.getByTestId("trigger-dag-button");
+    // Scoped to the dialog so we never accidentally click the page-level 
trigger.
+    this.confirmButton = page.getByRole("dialog").getByRole("button", { name: 
"Trigger" });
+    this.stateElement = page.getByTestId("dag-run-state");
     this.searchBox = page.getByRole("textbox", { name: /search/i });
     this.searchInput = page.getByPlaceholder("Search DAGs");
     this.operatorFilter = page.getByRole("combobox").filter({ hasText: 
/operator/i });
     this.triggerRuleFilter = page.getByRole("combobox").filter({ hasText: 
/trigger/i });
     this.retriesFilter = page.getByRole("combobox").filter({ hasText: /retr/i 
});
     // View toggle buttons
-    this.cardViewButton = page.locator('button[aria-label="Show card view"]');
-    this.tableViewButton = page.locator('button[aria-label="Show table 
view"]');
+    this.cardViewButton = page.getByRole("button", { name: "Show card view" });
+    this.tableViewButton = page.getByRole("button", { name: "Show table view" 
});
     // Status filter buttons
-    this.successFilter = page.locator('button:has-text("Success")');
-    this.failedFilter = page.locator('button:has-text("Failed")');
-    this.runningFilter = page.locator('button:has-text("Running")');
-    this.queuedFilter = page.locator('button:has-text("Queued")');
-    this.needsReviewFilter = page.locator('button:has-text("Needs Review")');
+    this.successFilter = page.getByRole("button", { name: "Success" });
+    this.failedFilter = page.getByRole("button", { name: "Failed" });
+    this.runningFilter = page.getByRole("button", { name: "Running" });
+    this.queuedFilter = page.getByRole("button", { name: "Queued" });
+    // Uses testId because this button's text is driven by an i18n key.
+    this.needsReviewFilter = page.getByTestId("dags-needs-review-filter");
   }
 
   // URL builders for dynamic paths
@@ -87,15 +86,19 @@ export class DagsPage extends BasePage {
    * Clear the search input and wait for list to reset
    */
   public async clearSearch(): Promise<void> {
-    await this.searchInput.clear();
-
-    // Trigger blur to ensure the clear action is processed
-    await this.searchInput.blur();
-
-    // Small delay to allow React to process the state change
-    await this.page.waitForTimeout(500);
+    const responsePromise = this.page
+      .waitForResponse((resp: Response) => resp.url().includes("/api/v2/dags") 
&& resp.status() === 200, {
+        timeout: 10_000,
+      })
+      .catch(() => {
+        /* API response may be cached */
+      });
 
-    // Wait for the DAG list to be visible again
+    // Click the clear button instead of programmatically clearing the input.
+    // The SearchBar component uses a 200ms debounce on keystroke changes,
+    // but the clear button calls onChange("") directly, bypassing the 
debounce.
+    await this.page.getByTestId("clear-search").click();
+    await responsePromise;
     await this.waitForDagList();
   }
 
@@ -121,8 +124,17 @@ export class DagsPage extends BasePage {
       success: this.successFilter,
     };
 
+    // Set up response listener before the click so we don't miss a fast 
response.
+    const responsePromise = this.page
+      .waitForResponse((resp: Response) => resp.url().includes("/api/v2/dags") 
&& resp.status() === 200, {
+        timeout: 10_000,
+      })
+      .catch(() => {
+        // Some filters are applied client-side and don't trigger a network 
request.
+      });
+
     await filterMap[status].click();
-    await this.page.waitForTimeout(500);
+    await responsePromise;
   }
 
   public async filterByTriggerRule(rule: string): Promise<void> {
@@ -187,32 +199,25 @@ export class DagsPage extends BasePage {
 
     if (isCardView) {
       // Card view: count dag-id elements
-      const dagCards = this.page.locator('[data-testid="dag-id"]');
-
-      return dagCards.count();
-    } else {
-      // Table view: count table body rows
-      const tableRows = this.page.locator('[data-testid="table-list"] tbody 
tr');
-
-      return tableRows.count();
+      return this.page.locator('[data-testid="dag-id"]').count();
     }
+
+    // Table view: count table body rows
+    return this.page.locator('[data-testid="table-list"] tbody tr').count();
   }
 
   public async getFilterOptions(filter: Locator): Promise<Array<string>> {
     await filter.click();
-    await this.page.waitForTimeout(500);
 
     const controlsId = await filter.getAttribute("aria-controls");
-    let options;
 
-    if (controlsId === null) {
-      const listbox = this.page.locator('div[role="listbox"]').first();
+    const dropdown =
+      controlsId === null
+        ? this.page.locator('div[role="listbox"]').first()
+        : this.page.locator(`[id="${controlsId}"]`);
 
-      await listbox.waitFor({ state: "visible", timeout: 5000 });
-      options = listbox.locator('div[role="option"]');
-    } else {
-      options = this.page.locator(`[id="${controlsId}"] div[role="option"]`);
-    }
+    await expect(dropdown).toBeVisible({ timeout: 5000 });
+    const options = dropdown.locator('div[role="option"]');
 
     const count = await options.count();
     const dataValues: Array<string> = [];
@@ -226,7 +231,11 @@ export class DagsPage extends BasePage {
     }
 
     await this.page.keyboard.press("Escape");
-    await this.page.waitForTimeout(300);
+    // Wait for the dropdown to close. On WebKit, the listbox may stay in the 
DOM
+    // with data-state="closed", while on other browsers it may be removed 
entirely.
+    await expect(dropdown)
+      .toBeHidden({ timeout: 5000 })
+      .catch(() => expect(dropdown).toHaveAttribute("data-state", "closed", { 
timeout: 1000 }));
 
     return dataValues;
   }
@@ -237,7 +246,7 @@ export class DagsPage extends BasePage {
   public async navigate(): Promise<void> {
     // Set up API listener before navigation
     const responsePromise = this.page
-      .waitForResponse((resp) => resp.url().includes("/api/v2/dags") && 
resp.status() === 200, {
+      .waitForResponse((resp: Response) => resp.url().includes("/api/v2/dags") 
&& resp.status() === 200, {
         timeout: 60_000,
       })
       .catch(() => {
@@ -248,9 +257,7 @@ export class DagsPage extends BasePage {
 
     // Wait for initial API response
     await responsePromise;
-
-    // Give UI time to render the response
-    await this.page.waitForTimeout(500);
+    await this.waitForDagList();
   }
 
   /**
@@ -262,11 +269,12 @@ export class DagsPage extends BasePage {
 
   public async navigateToDagTasks(dagId: string): Promise<void> {
     await this.page.goto(`/dags/${dagId}/tasks`);
-    await this.page
-      .locator("th")
-      .filter({ hasText: /^Operator$/ })
-      .first()
-      .waitFor({ state: "visible", timeout: 30_000 });
+    await expect(
+      this.page
+        .locator("th")
+        .filter({ hasText: /^Operator$/ })
+        .first(),
+    ).toBeVisible({ timeout: 30_000 });
   }
 
   /**
@@ -276,7 +284,7 @@ export class DagsPage extends BasePage {
     const currentNames = await this.getDagNames();
 
     const responsePromise = this.page
-      .waitForResponse((resp) => resp.url().includes("/dags") && resp.status() 
=== 200, {
+      .waitForResponse((resp: Response) => resp.url().includes("/dags") && 
resp.status() === 200, {
         timeout: 30_000,
       })
       .catch(() => {
@@ -284,13 +292,12 @@ export class DagsPage extends BasePage {
       });
 
     await this.searchInput.fill(searchTerm);
-
     await responsePromise;
 
     await expect
       .poll(
         async () => {
-          const noDagFound = this.page.locator("text=/no dag/i");
+          const noDagFound = this.page.getByText(/no dag/i);
           const isNoDagVisible = await noDagFound.isVisible().catch(() => 
false);
 
           if (isNoDagVisible) {
@@ -316,8 +323,6 @@ export class DagsPage extends BasePage {
     await expect(this.cardViewButton).toBeVisible({ timeout: 30_000 });
     await expect(this.cardViewButton).toBeEnabled({ timeout: 10_000 });
     await this.cardViewButton.click();
-    // Wait for card view to be rendered
-    await this.page.waitForTimeout(500);
     await this.verifyCardViewVisible();
   }
 
@@ -329,8 +334,6 @@ export class DagsPage extends BasePage {
     await expect(this.tableViewButton).toBeVisible({ timeout: 30_000 });
     await expect(this.tableViewButton).toBeEnabled({ timeout: 10_000 });
     await this.tableViewButton.click();
-    // Wait for table view to be rendered
-    await this.page.waitForTimeout(500);
     await this.verifyTableViewVisible();
   }
 
@@ -341,24 +344,15 @@ export class DagsPage extends BasePage {
     await this.navigateToDagDetail(dagName);
     await expect(this.triggerButton).toBeVisible({ timeout: 10_000 });
     await this.triggerButton.click();
-    const dagRunId = await this.handleTriggerDialog();
 
-    return dagRunId;
+    return this.handleTriggerDialog();
   }
 
   /**
    * Verify card view is visible
    */
-  public async verifyCardViewVisible(): Promise<boolean> {
-    const cardList = this.page.locator('[data-testid="card-list"]');
-
-    try {
-      await cardList.waitFor({ state: "visible", timeout: 10_000 });
-
-      return true;
-    } catch {
-      return false;
-    }
+  public async verifyCardViewVisible(): Promise<void> {
+    await expect(this.page.locator('[data-testid="card-list"]')).toBeVisible({ 
timeout: 10_000 });
   }
 
   /**
@@ -368,9 +362,6 @@ export class DagsPage extends BasePage {
     // Navigate directly to the details URL
     await this.page.goto(`/dags/${dagName}/details`, { waitUntil: 
"domcontentloaded" });
 
-    // Wait for page to load
-    await this.page.waitForTimeout(1000);
-
     // Use getByRole to precisely target the heading element
     // This avoids "strict mode violation" from matching breadcrumbs, file 
paths, etc.
     await expect(this.page.getByRole("heading", { name: dagName 
})).toBeVisible({ timeout: 30_000 });
@@ -379,7 +370,7 @@ export class DagsPage extends BasePage {
   /**
    * Verify if a specific Dag exists in the list
    */
-  public async verifyDagExists(dagId: string): Promise<boolean> {
+  public async verifyDagExists(dagId: string): Promise<void> {
     await this.waitForDagList();
 
     // Check which view is active
@@ -387,21 +378,14 @@ export class DagsPage extends BasePage {
     const isCardView = await cardList.isVisible();
 
     const dagLink = isCardView
-      ? this.page.locator(`[data-testid="dag-id"]:has-text("${dagId}")`)
-      : this.page.locator(`[data-testid="table-list"] tbody tr td 
a:has-text("${dagId}")`);
-
-    try {
-      await dagLink.waitFor({ state: "visible", timeout: 10_000 });
+      ? this.page.locator('[data-testid="dag-id"]').filter({ hasText: dagId })
+      : this.page.locator('[data-testid="table-list"] tbody tr td a').filter({ 
hasText: dagId });
 
-      return true;
-    } catch {
-      return false;
-    }
+    await expect(dagLink).toBeVisible({ timeout: 10_000 });
   }
 
   public async verifyDagRunStatus(dagName: string, dagRunId: string | null): 
Promise<void> {
-    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-    if (dagRunId === null || dagRunId === undefined || dagRunId === "") {
+    if (dagRunId === null || dagRunId === "") {
       return;
     }
 
@@ -410,29 +394,30 @@ export class DagsPage extends BasePage {
       waitUntil: "domcontentloaded",
     });
 
-    await this.page.waitForTimeout(2000);
-
-    const maxWaitTime = 7 * 60 * 1000;
-    const checkInterval = 10_000;
-    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 expect
+      .poll(
+        async () => {
+          await expect(this.stateElement).toBeVisible({ timeout: 30_000 });
 
-      await this.page.waitForTimeout(checkInterval);
+          const status = await this.getCurrentDagRunStatus();
 
-      await this.page.reload({ waitUntil: "domcontentloaded" });
+          if (status === "failed") {
+            throw new Error(`Dag run failed: ${dagRunId}`);
+          }
 
-      await this.page.waitForTimeout(2000);
-    }
+          if (status !== "success") {
+            await this.page.reload({ waitUntil: "domcontentloaded" });
+          }
 
-    throw new Error(`Dag run did not complete within 5 minutes: ${dagRunId}`);
+          return status;
+        },
+        {
+          intervals: [10_000],
+          message: `Dag run did not complete within the allowed time: 
${dagRunId}`,
+          timeout: 7 * 60 * 1000,
+        },
+      )
+      .toBe("success");
   }
 
   /**
@@ -445,16 +430,8 @@ export class DagsPage extends BasePage {
   /**
    * Verify table view is visible
    */
-  public async verifyTableViewVisible(): Promise<boolean> {
-    const table = this.page.locator("table");
-
-    try {
-      await table.waitFor({ state: "visible", timeout: 10_000 });
-
-      return true;
-    } catch {
-      return false;
-    }
+  public async verifyTableViewVisible(): Promise<void> {
+    await expect(this.page.locator("table")).toBeVisible({ timeout: 10_000 });
   }
 
   private async getCurrentDagRunStatus(): Promise<string> {
@@ -480,14 +457,10 @@ export class DagsPage extends BasePage {
   }
 
   private async handleTriggerDialog(): Promise<string | null> {
-    await this.page.waitForTimeout(1000);
-
     const responsePromise = this.page
-
       .waitForResponse(
-        (response) => {
+        (response: Response) => {
           const url = response.url();
-
           const method = response.request().method();
 
           return (
@@ -496,13 +469,11 @@ export class DagsPage extends BasePage {
         },
         { timeout: 10_000 },
       )
-
       .catch(() => undefined);
 
     await expect(this.confirmButton).toBeVisible({ timeout: 8000 });
-
-    await this.page.waitForTimeout(2000);
-    await this.confirmButton.click({ force: true });
+    await expect(this.confirmButton).toBeEnabled({ timeout: 10_000 });
+    await this.confirmButton.click();
 
     const apiResponse = await responsePromise;
 
@@ -525,8 +496,22 @@ export class DagsPage extends BasePage {
 
   private async selectDropdownOption(filter: Locator, value: string): 
Promise<void> {
     await filter.click();
-    await 
this.page.locator(`div[role="option"][data-value="${value}"]`).dispatchEvent("click");
-    await this.page.waitForTimeout(500);
+
+    const option = 
this.page.locator(`div[role="option"][data-value="${value}"]`);
+
+    await expect(option).toBeVisible({ timeout: 5000 });
+    await option.click();
+
+    // Ensure the dropdown closes after selection. Chakra Select may not 
auto-close
+    // reliably across browsers, so press Escape as a fallback.
+    const listbox = this.page.locator('div[role="listbox"]');
+
+    await expect(listbox)
+      .toBeHidden({ timeout: 5000 })
+      .catch(async () => {
+        await this.page.keyboard.press("Escape");
+        await expect(filter).toHaveAttribute("data-state", "closed", { 
timeout: 5000 });
+      });
   }
 
   /**
@@ -538,7 +523,7 @@ export class DagsPage extends BasePage {
     // (e.g. "No Dag found", "No Dags found", "NO DAG FOUND").
     const cardList = this.page.locator('[data-testid="card-list"]');
     const tableList = this.page.locator('[data-testid="table-list"]');
-    const noDagFound = this.page.locator("text=/no dag/i");
+    const noDagFound = this.page.getByText(/no dag/i);
     const fallbackTable = this.page.locator("table");
 
     // Wait for any of these elements to appear
@@ -561,20 +546,14 @@ export class DagsPage extends BasePage {
 
     if (isCardView) {
       // Card view: wait for dag-id elements
-      const dagCards = this.page.locator('[data-testid="dag-id"]');
-
-      await dagCards.first().waitFor({ state: "visible", timeout: 30_000 });
+      await 
expect(this.page.locator('[data-testid="dag-id"]').first()).toBeVisible({ 
timeout: 30_000 });
     } else {
       // Table view: prefer table-list testid, fallback to any <table> element
       const rowsInTableList = tableList.locator("tbody tr");
 
-      if ((await rowsInTableList.count().catch(() => 0)) > 0) {
-        await rowsInTableList.first().waitFor({ state: "visible", timeout: 
30_000 });
-      } else {
-        const anyTableRows = fallbackTable.locator("tbody tr");
-
-        await anyTableRows.first().waitFor({ state: "visible", timeout: 30_000 
});
-      }
+      await ((await rowsInTableList.count().catch(() => 0)) > 0
+        ? expect(rowsInTableList.first()).toBeVisible({ timeout: 30_000 })
+        : expect(fallbackTable.locator("tbody tr").first()).toBeVisible({ 
timeout: 30_000 }));
     }
   }
 }
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dags-list.spec.ts 
b/airflow-core/src/airflow/ui/tests/e2e/specs/dags-list.spec.ts
index 6d0a257757c..6181bbc47a7 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/specs/dags-list.spec.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dags-list.spec.ts
@@ -49,7 +49,7 @@ test.describe("Dag Details Tab", () => {
   });
 
   test("should successfully verify details tab", async () => {
-    test.setTimeout(120_000); // 2 minutes for slower browsers
+    test.setTimeout(120_000);
     await dagsPage.verifyDagDetails(testDagId);
   });
 });
@@ -62,7 +62,7 @@ test.describe("Dags List Display", () => {
   });
 
   test("should display Dags list after successful login", async () => {
-    test.setTimeout(120_000); // 2 minutes for slower browsers
+    test.setTimeout(120_000);
     await dagsPage.navigate();
     await dagsPage.verifyDagsListVisible();
 
@@ -72,7 +72,7 @@ test.describe("Dags List Display", () => {
   });
 
   test("should display Dag links correctly", async () => {
-    test.setTimeout(120_000); // 2 minutes for slower browsers
+    test.setTimeout(120_000);
     await dagsPage.navigate();
     await dagsPage.verifyDagsListVisible();
 
@@ -86,15 +86,12 @@ test.describe("Dags List Display", () => {
   });
 
   test("should display test Dag in the list", async () => {
-    test.setTimeout(120_000); // 2 minutes for slower browsers
+    test.setTimeout(120_000);
     const testDagId = testConfig.testDag.id;
 
     await dagsPage.navigate();
     await dagsPage.verifyDagsListVisible();
-
-    const dagExists = await dagsPage.verifyDagExists(testDagId);
-
-    expect(dagExists).toBe(true);
+    await dagsPage.verifyDagExists(testDagId);
   });
 });
 
@@ -106,25 +103,19 @@ test.describe("Dags View Toggle", () => {
   });
 
   test("should toggle between card view and table view", async () => {
-    test.setTimeout(120_000); // 2 minutes for slower browsers like Firefox
+    test.setTimeout(120_000);
     await dagsPage.navigate();
     await dagsPage.verifyDagsListVisible();
 
     await dagsPage.switchToCardView();
-
-    const cardViewVisible = await dagsPage.verifyCardViewVisible();
-
-    expect(cardViewVisible).toBe(true);
+    await dagsPage.verifyCardViewVisible();
 
     const cardViewDagsCount = await dagsPage.getDagsCount();
 
     expect(cardViewDagsCount).toBeGreaterThan(0);
 
     await dagsPage.switchToTableView();
-
-    const tableViewVisible = await dagsPage.verifyTableViewVisible();
-
-    expect(tableViewVisible).toBe(true);
+    await dagsPage.verifyTableViewVisible();
 
     const tableViewDagsCount = await dagsPage.getDagsCount();
 
@@ -142,7 +133,7 @@ test.describe("Dags Search", () => {
   });
 
   test("should search for a Dag by name", async () => {
-    test.setTimeout(120_000); // 2 minutes for slower browsers like Firefox
+    test.setTimeout(120_000);
     await dagsPage.navigate();
     await dagsPage.verifyDagsListVisible();
 
@@ -151,17 +142,11 @@ test.describe("Dags Search", () => {
     expect(initialCount).toBeGreaterThan(0);
 
     await dagsPage.searchDag(testDagId);
-
-    const dagExists = await dagsPage.verifyDagExists(testDagId);
-
-    expect(dagExists).toBe(true);
-
+    await dagsPage.verifyDagExists(testDagId);
     await dagsPage.clearSearch();
 
     await dagsPage.verifyDagsListVisible();
 
-    // Use poll to wait for the count to restore after clearing search
-    // This handles timing differences between local and CI environments
     await expect
       .poll(async () => dagsPage.getDagsCount(), {
         message: "Waiting for DAGs count to restore after clearing search",

Reply via email to