Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 74 additions & 4 deletions airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +93,18 @@ export class DagsPage extends BasePage {
await this.waitForDagList();
}

public async filterByOperator(operator: string): Promise<void> {
await this.selectDropdownOption(this.operatorFilter, operator);
}

public async filterByRetries(retries: string): Promise<void> {
await this.selectDropdownOption(this.retriesFilter, retries);
}

public async filterByTriggerRule(rule: string): Promise<void> {
await this.selectDropdownOption(this.triggerRuleFilter, rule);
}

/**
* Get all Dag names from the current page
*/
Expand All @@ -94,6 +116,39 @@ export class DagsPage extends BasePage {
return texts.map((text) => text.trim()).filter((text) => text !== "");
}

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();

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<string> = [];

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
*/
Expand All @@ -108,6 +163,15 @@ export class DagsPage extends BasePage {
await this.navigateTo(DagsPage.getDagDetailUrl(dagName));
}

public async navigateToDagTasks(dagId: string): Promise<void> {
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
*/
Expand Down Expand Up @@ -259,6 +323,12 @@ export class DagsPage extends BasePage {
return null;
}

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

/**
* Wait for DAG list to be rendered
*/
Expand Down
169 changes: 169 additions & 0 deletions airflow-core/src/airflow/ui/tests/e2e/specs/dag-tasks.spec.ts
Original file line number Diff line number Diff line change
@@ -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/.*`));
});
});
Loading