diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns.tsx b/airflow-core/src/airflow/ui/src/pages/DagRuns.tsx index b837cbee7c5e3..e508e245e28b3 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagRuns.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagRuns.tsx @@ -79,7 +79,7 @@ const runColumns = (translate: TFunction, dagId?: string): Array ( - + @@ -103,7 +103,11 @@ const runColumns = (translate: TFunction, dagId?: string): Array {translate(`common:states.${state}`)}, + }) => ( + + {translate(`common:states.${state}`)} + + ), header: () => translate("state"), }, { @@ -242,16 +246,18 @@ export const DagRuns = () => { return ( <> - } - initialState={tableURLState} - isLoading={isLoading} - modelName={translate("common:dagRun_other")} - onStateChange={setTableURLState} - total={data?.total_entries} - /> +
+ } + initialState={tableURLState} + isLoading={isLoading} + modelName={translate("common:dagRun_other")} + onStateChange={setTableURLState} + total={data?.total_entries} + /> +
); }; 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 51d0f23c81904..1c1ae2a61d196 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts @@ -30,33 +30,29 @@ export class DagsPage extends BasePage { return "/dags"; } + // Core page elements public readonly confirmButton: Locator; - public readonly operatorFilter: Locator; + public readonly dagsTable: Locator; + // Pagination elements public readonly paginationNextButton: Locator; public readonly paginationPrevButton: Locator; - public readonly retriesFilter: Locator; - public readonly searchBox: Locator; + // Runs tab elements + public readonly runsTab: Locator; + public readonly runsTable: 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 }); + this.runsTab = page.locator('a[href$="/runs"]'); + this.runsTable = page.locator('[data-testid="dag-runs-table"]'); } // URL builders for dynamic paths @@ -72,12 +68,7 @@ export class DagsPage extends BasePage { * Click next page button */ public async clickNextPage(): Promise { - const initialDagNames = await this.getDagNames(); - await this.paginationNextButton.click(); - - await expect.poll(() => this.getDagNames(), { timeout: 10_000 }).not.toEqual(initialDagNames); - await this.waitForDagList(); } @@ -85,24 +76,64 @@ export class DagsPage extends BasePage { * Click previous page button */ public async clickPrevPage(): Promise { - const initialDagNames = await this.getDagNames(); - await this.paginationPrevButton.click(); - - await expect.poll(() => this.getDagNames(), { timeout: 10_000 }).not.toEqual(initialDagNames); await this.waitForDagList(); } - public async filterByOperator(operator: string): Promise { - await this.selectDropdownOption(this.operatorFilter, operator); + /** + * Click on a specific run to view details + */ + public async clickRun(runId: string): Promise { + const runLink = this.page.locator(`a:has-text("${runId}")`).first(); + + await runLink.waitFor({ state: "visible" }); + await runLink.click(); + await this.page.waitForLoadState("networkidle"); + } + + /** + * Click next page button for runs + */ + public async clickRunPaginationNext(): Promise { + await this.paginationNextButton.click(); + await this.waitForRunList(); } - public async filterByRetries(retries: string): Promise { - await this.selectDropdownOption(this.retriesFilter, retries); + /** + * Click previous page button for runs + */ + public async clickRunPaginationPrev(): Promise { + await this.paginationPrevButton.click(); + await this.waitForRunList(); } - public async filterByTriggerRule(rule: string): Promise { - await this.selectDropdownOption(this.triggerRuleFilter, rule); + /** + * Filter runs by state + */ + public async filterByState(state: string): Promise { + // Use URL-based filtering instead of UI interaction for reliability + // The FilterBar component uses search params, so we can navigate directly + const currentUrl = this.page.url(); + const url = new URL(currentUrl); + + // Set the state parameter (convert to lowercase as API expects lowercase) + url.searchParams.set("state", state.toLowerCase()); + + // Navigate to the URL with the filter applied + await this.page.goto(url.toString(), { waitUntil: "networkidle" }); + + // Wait for the table to update with filtered results + await this.runsTable.waitFor({ state: "visible" }); + + // Wait for data to load - check for either rows or "no data" message + const runRows = this.page.locator('[data-testid="dag-runs-table"] table tbody tr'); + + try { + await runRows.first().waitFor({ state: "visible", timeout: 15_000 }); + } catch { + // If no rows appear, that's okay - might be no matching results + // The test will handle this + } } /** @@ -116,37 +147,32 @@ 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; + /** + * Get run details from the runs table + */ + public async getRunDetails(): Promise< + Array<{ + runId: string; + state: string; + }> + > { + const runRows = this.page.locator('[data-testid="dag-runs-table"] table tbody tr'); - if (controlsId === null) { - const listbox = this.page.locator('div[role="listbox"]').first(); + await runRows.first().waitFor({ state: "visible", timeout: 10_000 }); - await listbox.waitFor({ state: "visible", timeout: 5000 }); - options = listbox.locator('div[role="option"]'); - } else { - options = this.page.locator(`[id="${controlsId}"] div[role="option"]`); - } + const runCount = await runRows.count(); + const runs: Array<{ runId: string; state: string }> = []; - const count = await options.count(); - const dataValues: Array = []; + for (let i = 0; i < runCount; i++) { + const row = runRows.nth(i); - for (let i = 0; i < count; i++) { - const value = await options.nth(i).getAttribute("data-value"); + const runId = (await row.locator('[data-testid="run-id"]').textContent()) ?? ""; + const state = (await row.locator('[data-testid="run-state"]').textContent()) ?? ""; - if (value !== null && value.trim().length > 0) { - dataValues.push(value); - } + runs.push({ runId: runId.trim(), state: state.trim() }); } - await this.page.keyboard.press("Escape"); - await this.page.waitForTimeout(300); - - return dataValues; + return runs; } /** @@ -163,13 +189,36 @@ 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 }); + /** + * Navigate to the Runs tab for a specific DAG + */ + public async navigateToRunsTab(dagName: string): Promise { + await this.navigateToDagDetail(dagName); + await this.runsTab.waitFor({ state: "visible" }); + await this.runsTab.click(); + await this.runsTable.waitFor({ state: "visible", timeout: 30_000 }); + } + + /** + * Search for dag runs by run ID pattern + */ + public async searchRun(searchTerm: string): Promise { + // Find the run ID pattern input field + const searchInput = this.page + .locator('input[placeholder*="Run ID"]') + .or(this.page.locator('input[name="runIdPattern"]')); + + await searchInput.waitFor({ state: "visible" }); + await searchInput.fill(searchTerm); + + // Wait for the search to take effect + await this.page.waitForResponse( + (response) => + response.url().includes("dagRuns") && + response.request().method() === "GET" && + response.status() === 200, + ); + await this.page.waitForLoadState("networkidle"); } /** @@ -177,16 +226,13 @@ export class DagsPage extends BasePage { */ public async triggerDag(dagName: string): Promise { await this.navigateToDagDetail(dagName); - await expect(this.triggerButton).toBeVisible({ timeout: 10_000 }); + await this.triggerButton.waitFor({ state: "visible", timeout: 30_000 }); await this.triggerButton.click(); const dagRunId = await this.handleTriggerDialog(); return dagRunId; } - /** - * Navigate to details tab and verify Dag details are displayed correctly - */ public async verifyDagDetails(dagName: string): Promise { await this.navigateToDagDetail(dagName); @@ -241,10 +287,8 @@ export class DagsPage extends BasePage { while (Date.now() - startTime < maxWaitTime) { const currentStatus = await this.getCurrentDagRunStatus(); - if (currentStatus === "success") { + if (currentStatus === "success" || currentStatus === "failed") { return; - } else if (currentStatus === "failed") { - throw new Error(`Dag run failed: ${dagRunId}`); } await this.page.waitForTimeout(checkInterval); @@ -257,6 +301,25 @@ export class DagsPage extends BasePage { throw new Error(`Dag run did not complete within 5 minutes: ${dagRunId}`); } + /** + * Verify we're on the run details page + */ + public async verifyRunDetailsPage(runId: string): Promise { + // Wait for the page to load + await this.page.waitForLoadState("networkidle"); + + // Verify URL contains the run ID + await expect(this.page).toHaveURL(new RegExp(`/runs/${runId.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&")}`)); + } + + /** + * Verify the Runs tab is displayed correctly + */ + public async verifyRunsTabDisplayed(): Promise { + // Verify the runs table is present + await expect(this.runsTable).toBeVisible(); + } + private async getCurrentDagRunStatus(): Promise { try { const statusText = await this.stateElement.textContent().catch(() => ""); @@ -323,12 +386,6 @@ 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 */ @@ -337,4 +394,10 @@ export class DagsPage extends BasePage { timeout: 10_000, }); } + + private async waitForRunList(): Promise { + await expect(this.page.locator('[data-testid="run-id"]').first()).toBeVisible({ + timeout: 10_000, + }); + } } 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 index 718f00b7cd0d6..8bacea57c8e87 100644 --- 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 @@ -16,131 +16,116 @@ * specific language governing permissions and limitations * under the License. */ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; import { AUTH_FILE, testConfig } from "playwright.config"; -import { DagRunsPage } from "tests/e2e/pages/DagRunsPage"; +import { DagsPage } from "tests/e2e/pages/DagsPage"; -test.describe("DAG Runs Page", () => { - test.setTimeout(60_000); - - let dagRunsPage: DagRunsPage; - const testDagId1 = testConfig.testDag.id; - const testDagId2 = "example_python_operator"; +test.describe("Dag Run Tests", () => { + const testDagId = testConfig.testDag.id; test.beforeAll(async ({ browser }) => { + test.setTimeout(5 * 60 * 1000); + 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(); + const setupPage = new DagsPage(page); + + await setupPage.triggerDag(testDagId); + await setupPage.triggerDag(testDagId); + + const response = await page.request.get(`/api/v2/dags/${testDagId}/dagRuns?limit=1`); + + expect(response.ok()).toBeTruthy(); + const data = (await response.json()) as { dag_runs?: Array<{ dag_run_id?: string }> }; + const runId = data.dag_runs?.[0]?.dag_run_id; + + if (runId !== undefined && runId !== "") { + await page.request.patch(`/api/v2/dags/${testDagId}/dagRuns/${runId}`, { + data: { state: "failed" }, + }); + } await context.close(); }); - test.beforeEach(({ page }) => { - dagRunsPage = new DagRunsPage(page); - }); + test("verify runs table displays with valid data", async ({ page }) => { + const dagsPage = new DagsPage(page); - test("verify DAG runs table displays data", async () => { - await dagRunsPage.navigate(); - await dagRunsPage.verifyDagRunsExist(); - }); + await dagsPage.navigateToRunsTab(testDagId); - test("verify run details display correctly", async () => { - await dagRunsPage.navigate(); - await dagRunsPage.verifyRunDetailsDisplay(); - }); + // Verify runs table is displayed + await dagsPage.verifyRunsTabDisplayed(); - test("verify filtering by failed state", async () => { - await dagRunsPage.navigate(); - await dagRunsPage.verifyStateFiltering("Failed"); + // Verify we can see run details + const runs = await dagsPage.getRunDetails(); + + expect(runs.length).toBeGreaterThan(0); }); - test("verify filtering by success state", async () => { - await dagRunsPage.navigate(); - await dagRunsPage.verifyStateFiltering("Success"); + test("verify run details page navigation", async ({ page }) => { + const dagsPage = new DagsPage(page); + + await dagsPage.navigateToRunsTab(testDagId); + + const runs = await dagsPage.getRunDetails(); + + expect(runs.length).toBeGreaterThan(0); + + const [firstRun] = runs; + + if (firstRun === undefined) { + throw new Error("No runs found"); + } + + await dagsPage.clickRun(firstRun.runId); + + // Verify we're on the run details page + await dagsPage.verifyRunDetailsPage(firstRun.runId); }); - test("verify filtering by DAG ID", async () => { - await dagRunsPage.navigate(); - await dagRunsPage.verifyDagIdFiltering(testDagId1); + test("verify filtering", async ({ page }) => { + const dagsPage = new DagsPage(page); + + await dagsPage.navigateToRunsTab(testDagId); + + const runs = await dagsPage.getRunDetails(); + + expect(runs.length).toBeGreaterThan(0); + + // We created a failed run in beforeAll, so filter by 'Failed' + const targetState = "Failed"; + + await dagsPage.filterByState(targetState); + + const filteredRuns = await dagsPage.getRunDetails(); + + // Verify filtering works - all results should match the target state + expect(filteredRuns.length).toBeGreaterThan(0); + expect(filteredRuns.every((run) => run.state === targetState)).toBeTruthy(); }); - test("verify pagination with offset and limit", async () => { - await dagRunsPage.verifyPagination(3); + test("verify pagination", async ({ page }) => { + const dagsPage = new DagsPage(page); + + await dagsPage.navigateToRunsTab(testDagId); + + const initialRuns = await dagsPage.getRunDetails(); + + expect(initialRuns.length).toBeGreaterThan(0); + + const firstRunId = initialRuns[0]?.runId; + + await dagsPage.clickRunPaginationNext(); + + const nextPageRuns = await dagsPage.getRunDetails(); + + expect(nextPageRuns.length).toBeGreaterThan(0); + expect(nextPageRuns[0]?.runId).not.toEqual(firstRunId); + + await dagsPage.clickRunPaginationPrev(); + + const prevPageRuns = await dagsPage.getRunDetails(); + + expect(prevPageRuns[0]?.runId).toEqual(firstRunId); }); }); 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 43eda1882fc43..8e9ff9e8459dc 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 @@ -19,15 +19,23 @@ import { expect, test } from "@playwright/test"; import { testConfig } from "playwright.config"; import { DagsPage } from "tests/e2e/pages/DagsPage"; +import { LoginPage } from "tests/e2e/pages/LoginPage"; test.describe("Dags Pagination", () => { + let loginPage: LoginPage; let dagsPage: DagsPage; + const testCredentials = testConfig.credentials; + test.beforeEach(({ page }) => { + loginPage = new LoginPage(page); dagsPage = new DagsPage(page); }); test("should verify pagination works on the Dags list page", async () => { + await loginPage.navigateAndLogin(testCredentials.username, testCredentials.password); + await loginPage.expectLoginSuccess(); + await dagsPage.navigate(); await expect(dagsPage.paginationNextButton).toBeVisible(); @@ -52,35 +60,4 @@ test.describe("Dags Pagination", () => { }); }); -test.describe("Dag Trigger Workflow", () => { - let dagsPage: DagsPage; - const testDagId = testConfig.testDag.id; - - test.beforeEach(({ page }) => { - dagsPage = new DagsPage(page); - }); - - test("should successfully trigger a Dag run", async () => { - test.setTimeout(7 * 60 * 1000); - - const dagRunId = await dagsPage.triggerDag(testDagId); - - if (Boolean(dagRunId)) { - await dagsPage.verifyDagRunStatus(testDagId, dagRunId); - } - }); -}); - -test.describe("Dag Details Tab", () => { - let dagsPage: DagsPage; - - const testDagId = testConfig.testDag.id; - - test.beforeEach(({ page }) => { - dagsPage = new DagsPage(page); - }); - - test("should successfully verify details tab", async () => { - await dagsPage.verifyDagDetails(testDagId); - }); -}); +// End of file