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",