Skip to content
Merged
188 changes: 188 additions & 0 deletions airflow-core/src/airflow/ui/tests/e2e/pages/DagRunsPage.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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());
}
}
}
146 changes: 146 additions & 0 deletions airflow-core/src/airflow/ui/tests/e2e/specs/dag-runs.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading