diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts new file mode 100644 index 0000000000000..080def6218d47 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts @@ -0,0 +1,188 @@ +/*! + * 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 DagRunsPage extends BasePage { + public static get dagRunsUrl(): string { + return "/dag_runs"; + } + + public readonly dagRunsTable: Locator; + + public constructor(page: Page) { + super(page); + this.dagRunsTable = page.locator('table, div[role="table"]'); + } + + /** + * Navigate to DAG Runs page and wait for data to load + */ + public async navigate(): Promise { + await this.navigateTo(DagRunsPage.dagRunsUrl); + await this.page.waitForURL(/.*dag_runs/, { timeout: 15_000 }); + await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); + + const dataLink = this.dagRunsTable.locator("a[href*='/dags/']").first(); + const noDataMessage = this.page.locator('text="No Dag Runs found"'); + + await expect(dataLink.or(noDataMessage)).toBeVisible({ timeout: 30_000 }); + } + + /** + * Verify DAG ID filtering via URL parameters + */ + public async verifyDagIdFiltering(dagIdPattern: string): Promise { + await this.navigateTo(`${DagRunsPage.dagRunsUrl}?dag_id_pattern=${encodeURIComponent(dagIdPattern)}`); + await this.page.waitForURL(/.*dag_id_pattern=.*/, { timeout: 15_000 }); + await this.page.waitForLoadState("networkidle"); + + const dataLinks = this.dagRunsTable.locator("a[href*='/dags/']"); + + await expect(dataLinks.first()).toBeVisible({ timeout: 30_000 }); + await expect(this.dagRunsTable).toBeVisible(); + + const rows = this.dagRunsTable.locator("tbody tr"); + const rowCount = await rows.count(); + + expect(rowCount).toBeGreaterThan(0); + + for (let i = 0; i < Math.min(rowCount, 5); i++) { + const dagIdLink = rows.nth(i).locator("a[href*='/dags/']").first(); + const dagIdText = await dagIdLink.textContent(); + + expect(dagIdText).toBeTruthy(); + expect(dagIdText).toContain(dagIdPattern); + } + } + + /** + * Verify that the table contains DAG run data + */ + public async verifyDagRunsExist(): Promise { + const dataLinks = this.dagRunsTable.locator("a[href*='/dags/']"); + + await expect(dataLinks.first()).toBeVisible({ timeout: 30_000 }); + expect(await dataLinks.count()).toBeGreaterThan(0); + } + + /** + * Verify pagination controls and navigation + */ + public async verifyPagination(limit: number): Promise { + await this.navigateTo(`${DagRunsPage.dagRunsUrl}?offset=0&limit=${limit}`); + await this.page.waitForURL(/.*limit=/, { timeout: 10_000 }); + await this.page.waitForLoadState("networkidle"); + await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); + + const dataLinks = this.dagRunsTable.locator("a[href*='/dags/']"); + + await expect(dataLinks.first()).toBeVisible({ timeout: 30_000 }); + + const rows = this.dagRunsTable.locator("tbody tr"); + + expect(await rows.count()).toBeGreaterThan(0); + + const paginationNav = this.page.locator('nav[aria-label="pagination"], [role="navigation"]'); + + await expect(paginationNav.first()).toBeVisible({ timeout: 10_000 }); + + const page1Button = this.page.getByRole("button", { name: /page 1|^1$/ }); + + await expect(page1Button.first()).toBeVisible({ timeout: 5000 }); + + const page2Button = this.page.getByRole("button", { name: /page 2|^2$/ }); + const hasPage2 = await page2Button + .first() + .isVisible() + .catch(() => false); + + if (hasPage2) { + await page2Button.first().click(); + await this.page.waitForLoadState("networkidle"); + await this.dagRunsTable.waitFor({ state: "visible", timeout: 10_000 }); + + const dataLinksPage2 = this.dagRunsTable.locator("a[href*='/dags/']"); + const noDataMessage = this.page.locator("text=/no.*data|no.*runs|no.*results/i"); + + await expect(dataLinksPage2.first().or(noDataMessage.first())).toBeVisible({ timeout: 30_000 }); + } + } + + /** + * Verify that run details are displayed in the table row + */ + public async verifyRunDetailsDisplay(): Promise { + const firstRow = this.dagRunsTable.locator("tbody tr").first(); + + await expect(firstRow).toBeVisible({ timeout: 10_000 }); + + const dagIdLink = firstRow.locator("a[href*='/dags/']").first(); + + await expect(dagIdLink).toBeVisible(); + expect((await dagIdLink.textContent())?.trim()).toBeTruthy(); + + const runIdLink = firstRow.locator("a[href*='/runs/']").first(); + + await expect(runIdLink).toBeVisible(); + expect((await runIdLink.textContent())?.trim()).toBeTruthy(); + + const stateCell = firstRow.locator("td").filter({ hasText: /running|success|failed|queued/i }); + + await expect(stateCell.first()).toBeVisible(); + + const timeElements = firstRow.locator("time"); + + if ((await timeElements.count()) > 0) { + await expect(timeElements.first()).toBeVisible(); + } else { + const cellTexts = await firstRow.locator("td").allTextContents(); + const hasDateFormat = cellTexts.some((text) => + /\d{4}(?:-\d{2}){2}|(?:\d{1,2}\/){2}\d{4}|(?:\d{1,2}:){2}\d{2}/.test(text), + ); + + expect(hasDateFormat).toBeTruthy(); + } + } + + /** + * Verify state filtering via URL parameters + */ + public async verifyStateFiltering(expectedState: string): Promise { + await this.navigateTo(`${DagRunsPage.dagRunsUrl}?state=${expectedState.toLowerCase()}`); + await this.page.waitForURL(/.*state=.*/, { timeout: 15_000 }); + await this.page.waitForLoadState("networkidle"); + + const dataLinks = this.dagRunsTable.locator("a[href*='/dags/']"); + + await expect(dataLinks.first()).toBeVisible({ timeout: 30_000 }); + await expect(this.dagRunsTable).toBeVisible(); + + const rows = this.dagRunsTable.locator("tbody tr"); + const rowCount = await rows.count(); + + expect(rowCount).toBeGreaterThan(0); + + for (let i = 0; i < rowCount; i++) { + const rowText = await rows.nth(i).textContent(); + + expect(rowText?.toLowerCase()).toContain(expectedState.toLowerCase()); + } + } +} diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts new file mode 100644 index 0000000000000..718f00b7cd0d6 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts @@ -0,0 +1,146 @@ +/*! + * 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 { AUTH_FILE, testConfig } from "playwright.config"; +import { DagRunsPage } from "tests/e2e/pages/DagRunsPage"; + +test.describe("DAG Runs Page", () => { + test.setTimeout(60_000); + + let dagRunsPage: DagRunsPage; + const testDagId1 = testConfig.testDag.id; + const testDagId2 = "example_python_operator"; + + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + const baseUrl = process.env.AIRFLOW_UI_BASE_URL ?? "http://localhost:8080"; + + const timestamp = Date.now(); + + // Trigger first DAG run and mark as failed + const runId1 = `test_run_failed_${timestamp}`; + const logicalDate1 = new Date(timestamp).toISOString(); + const triggerResponse1 = await page.request.post(`${baseUrl}/api/v2/dags/${testDagId1}/dagRuns`, { + data: JSON.stringify({ + dag_run_id: runId1, + logical_date: logicalDate1, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + expect(triggerResponse1.ok()).toBeTruthy(); + const runData1 = (await triggerResponse1.json()) as { dag_run_id: string }; + + // Mark the first run as failed + const patchResponse1 = await page.request.patch( + `${baseUrl}/api/v2/dags/${testDagId1}/dagRuns/${runData1.dag_run_id}`, + { + data: JSON.stringify({ state: "failed" }), + headers: { + "Content-Type": "application/json", + }, + }, + ); + + expect(patchResponse1.ok()).toBeTruthy(); + + // Trigger second DAG run and mark as success + const runId2 = `test_run_success_${timestamp}`; + const logicalDate2 = new Date(timestamp + 60_000).toISOString(); + const triggerResponse2 = await page.request.post(`${baseUrl}/api/v2/dags/${testDagId1}/dagRuns`, { + data: JSON.stringify({ + dag_run_id: runId2, + logical_date: logicalDate2, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + expect(triggerResponse2.ok()).toBeTruthy(); + const runData2 = (await triggerResponse2.json()) as { dag_run_id: string }; + + // Mark the second run as success + const patchResponse2 = await page.request.patch( + `${baseUrl}/api/v2/dags/${testDagId1}/dagRuns/${runData2.dag_run_id}`, + { + data: JSON.stringify({ state: "success" }), + headers: { + "Content-Type": "application/json", + }, + }, + ); + + expect(patchResponse2.ok()).toBeTruthy(); + + // Trigger a run for a different DAG (for DAG ID filtering test) + const runId3 = `test_run_other_dag_${timestamp}`; + const logicalDate3 = new Date(timestamp + 120_000).toISOString(); + + const triggerResponse3 = await page.request.post(`${baseUrl}/api/v2/dags/${testDagId2}/dagRuns`, { + data: JSON.stringify({ + dag_run_id: runId3, + logical_date: logicalDate3, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + expect(triggerResponse3.ok()).toBeTruthy(); + + await context.close(); + }); + + test.beforeEach(({ page }) => { + dagRunsPage = new DagRunsPage(page); + }); + + test("verify DAG runs table displays data", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyDagRunsExist(); + }); + + test("verify run details display correctly", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyRunDetailsDisplay(); + }); + + test("verify filtering by failed state", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyStateFiltering("Failed"); + }); + + test("verify filtering by success state", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyStateFiltering("Success"); + }); + + test("verify filtering by DAG ID", async () => { + await dagRunsPage.navigate(); + await dagRunsPage.verifyDagIdFiltering(testDagId1); + }); + + test("verify pagination with offset and limit", async () => { + await dagRunsPage.verifyPagination(3); + }); +});