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 4ffe0878859d9..51d0f23c81904 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts @@ -30,23 +30,33 @@ export class DagsPage extends BasePage { return "/dags"; } - // Core page elements public readonly confirmButton: Locator; - public readonly dagsTable: Locator; - // Pagination elements + public readonly operatorFilter: Locator; public readonly paginationNextButton: Locator; public readonly paginationPrevButton: Locator; + public readonly retriesFilter: Locator; + public readonly searchBox: Locator; public readonly stateElement: Locator; public readonly triggerButton: Locator; + public readonly triggerRuleFilter: Locator; + + public get taskCards(): Locator { + // CardList component renders a SimpleGrid with data-testid="card-list" + // Individual cards are direct children (Box elements) + return this.page.locator('[data-testid="card-list"] > div'); + } public constructor(page: Page) { super(page); - this.dagsTable = page.locator('div:has(a[href*="/dags/"])'); this.triggerButton = page.locator('button[aria-label="Trigger Dag"]:has-text("Trigger")'); this.confirmButton = page.locator('button:has-text("Trigger")').nth(1); this.stateElement = page.locator('*:has-text("State") + *').first(); this.paginationNextButton = page.locator('[data-testid="next"]'); this.paginationPrevButton = page.locator('[data-testid="prev"]'); + this.searchBox = page.getByRole("textbox", { name: /search/i }); + 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 }); } // URL builders for dynamic paths @@ -83,6 +93,18 @@ export class DagsPage extends BasePage { await this.waitForDagList(); } + public async filterByOperator(operator: string): Promise { + await this.selectDropdownOption(this.operatorFilter, operator); + } + + public async filterByRetries(retries: string): Promise { + await this.selectDropdownOption(this.retriesFilter, retries); + } + + public async filterByTriggerRule(rule: string): Promise { + await this.selectDropdownOption(this.triggerRuleFilter, rule); + } + /** * Get all Dag names from the current page */ @@ -94,6 +116,39 @@ export class DagsPage extends BasePage { return texts.map((text) => text.trim()).filter((text) => text !== ""); } + public async getFilterOptions(filter: Locator): Promise> { + 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(); + + await listbox.waitFor({ state: "visible", timeout: 5000 }); + options = listbox.locator('div[role="option"]'); + } else { + options = this.page.locator(`[id="${controlsId}"] div[role="option"]`); + } + + const count = await options.count(); + const dataValues: Array = []; + + for (let i = 0; i < count; i++) { + const value = await options.nth(i).getAttribute("data-value"); + + if (value !== null && value.trim().length > 0) { + dataValues.push(value); + } + } + + await this.page.keyboard.press("Escape"); + await this.page.waitForTimeout(300); + + return dataValues; + } + /** * Navigate to Dags list page */ @@ -108,6 +163,15 @@ export class DagsPage extends BasePage { await this.navigateTo(DagsPage.getDagDetailUrl(dagName)); } + public async navigateToDagTasks(dagId: string): Promise { + await this.page.goto(`/dags/${dagId}/tasks`); + await this.page + .locator("h2") + .filter({ hasText: /^Operator$/ }) + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + } + /** * Trigger a Dag run */ @@ -259,6 +323,12 @@ export class DagsPage extends BasePage { return null; } + private async selectDropdownOption(filter: Locator, value: string): Promise { + await filter.click(); + await this.page.locator(`div[role="option"][data-value="${value}"]`).dispatchEvent("click"); + await this.page.waitForTimeout(500); + } + /** * Wait for DAG list to be rendered */ diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-tasks.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-tasks.spec.ts new file mode 100644 index 0000000000000..4177d626a9677 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-tasks.spec.ts @@ -0,0 +1,169 @@ +/*! + * 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, AUTH_FILE } from "playwright.config"; +import { DagsPage } from "tests/e2e/pages/DagsPage"; + +test.describe("Dag Tasks Tab", () => { + const testDagId = testConfig.testDag.id; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(7 * 60 * 1000); + + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + const dagPage = new DagsPage(page); + + const dagRunId = await dagPage.triggerDag(testDagId); + + await dagPage.verifyDagRunStatus(testDagId, dagRunId); + + await context.close(); + }); + test("verify tasks tab displays task list", async ({ page }) => { + const dagPage = new DagsPage(page); + + await dagPage.navigateToDagTasks(testDagId); + + await expect(page).toHaveURL(/\/tasks$/); + await expect(dagPage.taskCards.first()).toBeVisible(); + + const firstCard = dagPage.taskCards.first(); + + await expect(firstCard.locator("a").first()).toBeVisible(); + await expect(firstCard).toContainText("Operator"); + await expect(firstCard).toContainText("Trigger Rule"); + await expect(firstCard).toContainText("Last Instance"); + }); + + test("verify search tasks by name", async ({ page }) => { + const dagPage = new DagsPage(page); + + await dagPage.navigateToDagTasks(testDagId); + + const firstTaskLink = dagPage.taskCards.first().locator("a").first(); + const taskName = await firstTaskLink.textContent(); + + if (taskName === null) { + throw new Error("Task name not found"); + } + + await dagPage.searchBox.fill(taskName); + + await expect.poll(() => dagPage.taskCards.count(), { timeout: 20_000 }).toBe(1); + await expect(dagPage.taskCards).toContainText(taskName); + }); + + test("verify filter tasks by operator dropdown", async ({ page }) => { + const dagPage = new DagsPage(page); + + await dagPage.navigateToDagTasks(testDagId); + + const operators = await dagPage.getFilterOptions(dagPage.operatorFilter); + + expect(operators.length).toBeGreaterThan(0); + + for (const operator of operators) { + await dagPage.filterByOperator(operator); + + await expect + .poll( + async () => { + const count = await dagPage.taskCards.count(); + + if (count === 0) return false; + for (let i = 0; i < count; i++) { + const text = await dagPage.taskCards.nth(i).textContent(); + + if (!text?.includes(operator)) return false; + } + + return true; + }, + { timeout: 20_000 }, + ) + .toBeTruthy(); + + await dagPage.navigateToDagTasks(testDagId); + } + }); + + test("verify filter tasks by trigger rule dropdown", async ({ page }) => { + const dagPage = new DagsPage(page); + + await dagPage.navigateToDagTasks(testDagId); + + const rules = await dagPage.getFilterOptions(dagPage.triggerRuleFilter); + + expect(rules.length).toBeGreaterThan(0); + + for (const rule of rules) { + await dagPage.filterByTriggerRule(rule); + + await expect + .poll( + async () => { + const count = await dagPage.taskCards.count(); + + if (count === 0) return false; + for (let i = 0; i < count; i++) { + const text = await dagPage.taskCards.nth(i).textContent(); + + if (!text?.includes(rule)) return false; + } + + return true; + }, + { timeout: 20_000 }, + ) + .toBeTruthy(); + + await dagPage.navigateToDagTasks(testDagId); + } + }); + + test("verify filter by retries", async ({ page }) => { + const dagPage = new DagsPage(page); + + await dagPage.navigateToDagTasks(testDagId); + + const retriesOptions = await dagPage.getFilterOptions(dagPage.retriesFilter); + + if (retriesOptions.length === 0) { + return; + } + + for (const retries of retriesOptions) { + await dagPage.filterByRetries(retries); + await expect(dagPage.taskCards.first()).toBeVisible(); + await dagPage.navigateToDagTasks(testDagId); + } + }); + test("verify click task to show details", async ({ page }) => { + const dagPage = new DagsPage(page); + + await dagPage.navigateToDagTasks(testDagId); + + const firstCard = dagPage.taskCards.first(); + const taskLink = firstCard.locator("a").first(); + + await taskLink.click(); + await expect(page).toHaveURL(new RegExp(`/dags/${testDagId}/tasks/.*`)); + }); +});