diff --git a/airflow-core/src/airflow/ui/playwright.config.ts b/airflow-core/src/airflow/ui/playwright.config.ts index c6ec3b7617090..96373d2a09742 100644 --- a/airflow-core/src/airflow/ui/playwright.config.ts +++ b/airflow-core/src/airflow/ui/playwright.config.ts @@ -120,7 +120,7 @@ export default defineConfig({ "**/variable.spec.ts", ], - timeout: 30_000, + timeout: 60_000, use: { actionTimeout: 10_000, baseURL: process.env.AIRFLOW_UI_BASE_URL ?? "http://localhost:28080", diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts index 44a897e466cb7..968b62b1e7391 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/BasePage.ts @@ -18,6 +18,7 @@ */ import { expect } from "@playwright/test"; import type { Page, Locator } from "@playwright/test"; +import { waitForServerReady } from "tests/e2e/utils/health"; /** * Base Page Object @@ -53,8 +54,12 @@ export class BasePage { } public async navigateTo(path: string): Promise { - await this.page.goto(path, { - waitUntil: "domcontentloaded", - }); + await this.safeGoto(path, { waitUntil: "domcontentloaded" }); + } + + /** Health-checked navigation. Subclasses should use this instead of `this.page.goto()`. */ + protected async safeGoto(path: string, options?: Parameters[1]): Promise { + await waitForServerReady(this.page); + await this.page.goto(path, options); } } diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts index 31a1dfe7f9481..cf43c60195dea 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts @@ -215,7 +215,7 @@ export class ConnectionsPage extends BasePage { await expect(selectCombobox).toBeEnabled({ timeout: 25_000 }); - await selectCombobox.click({ timeout: 3000 }); + await selectCombobox.click({ timeout: 10_000 }); // Wait for options to appear and click the matching option const option = this.page.getByRole("option", { name: new RegExp(details.conn_type, "i") }).first(); diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts index dcf1d6dc7dc4e..7aee20db7c083 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts @@ -69,10 +69,13 @@ export class DagCalendarTab extends BasePage { for (let i = 0; i < count; i++) { const cell = this.activeCells.nth(i); - await cell.hover(); - await expect(this.tooltip).toBeVisible({ timeout: 20_000 }); + let text = ""; - const text = ((await this.tooltip.textContent()) ?? "").toLowerCase(); + await expect(async () => { + await cell.hover({ force: true }); + await expect(this.tooltip).toBeVisible({ timeout: 3000 }); + text = ((await this.tooltip.textContent()) ?? "").toLowerCase(); + }).toPass({ intervals: [500], timeout: 20_000 }); if (text.includes("success")) states.push("success"); if (text.includes("failed")) states.push("failed"); @@ -84,7 +87,7 @@ export class DagCalendarTab extends BasePage { public async navigateToCalendar(dagId: string) { await expect(async () => { - await this.page.goto(`/dags/${dagId}/calendar`); + await this.safeGoto(`/dags/${dagId}/calendar`); await this.page.getByTestId("dag-calendar-root").waitFor({ state: "visible", timeout: 5000 }); }).toPass({ intervals: [2000], timeout: 60_000 }); await this.waitForCalendarReady(); diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCodePage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCodePage.ts index fbb2f27ab59b8..b45bb4dd88d45 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCodePage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCodePage.ts @@ -38,7 +38,7 @@ export class DagCodePage extends BasePage { public async navigateToCodeTab(dagId: string): Promise { await expect(async () => { await this.navigateTo(`/dags/${dagId}/code`); - await expect(this.editorContainer).toBeVisible({ timeout: 5000 }); + await expect(this.editorContainer).toBeVisible({ timeout: 10_000 }); }).toPass({ intervals: [2000], timeout: 60_000 }); await this.waitForCodeReady(); } 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 d383593ab16f4..4b9a5a65d28f7 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagsPage.ts @@ -269,7 +269,7 @@ export class DagsPage extends BasePage { public async navigateToDagTasks(dagId: string): Promise { await expect(async () => { - await this.page.goto(`/dags/${dagId}/tasks`); + await this.safeGoto(`/dags/${dagId}/tasks`); await expect( this.page .locator("th") @@ -364,7 +364,7 @@ export class DagsPage extends BasePage { */ public async verifyDagDetails(dagName: string): Promise { await expect(async () => { - await this.page.goto(`/dags/${dagName}/details`, { waitUntil: "domcontentloaded" }); + await this.safeGoto(`/dags/${dagName}/details`, { waitUntil: "domcontentloaded" }); await expect(this.page.getByRole("heading", { name: dagName })).toBeVisible({ timeout: 30_000 }); }).toPass({ intervals: [2000], timeout: 60_000 }); } @@ -391,7 +391,7 @@ export class DagsPage extends BasePage { return; } - await this.page.goto(DagsPage.getDagRunDetailsUrl(dagName, dagRunId), { + await this.safeGoto(DagsPage.getDagRunDetailsUrl(dagName, dagRunId), { timeout: 15_000, waitUntil: "domcontentloaded", }); diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts index d9bbf727420d9..5c72b4313115f 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts @@ -120,7 +120,7 @@ export class EventsPage extends BasePage { public async navigateToAuditLog(dagId: string): Promise { await expect(async () => { - await this.page.goto(EventsPage.getEventsUrl(dagId), { + await this.safeGoto(EventsPage.getEventsUrl(dagId), { timeout: 10_000, waitUntil: "domcontentloaded", }); diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/RequiredActionsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/RequiredActionsPage.ts index fc08d6b3a912e..db04ec56cc9c9 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/RequiredActionsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/RequiredActionsPage.ts @@ -93,7 +93,7 @@ export class RequiredActionsPage extends BasePage { const buttonName = approve ? "Approve" : "Reject"; const actionButton = this.page.getByTestId(`hitl-option-${buttonName}`); - await expect(actionButton).toBeVisible({ timeout: 10_000 }); + await expect(actionButton).toBeVisible({ timeout: 30_000 }); const informationInput = this.page.getByRole("textbox"); @@ -104,7 +104,7 @@ export class RequiredActionsPage extends BasePage { await expect(actionButton).toBeEnabled({ timeout: 10_000 }); await this.clickButtonAndWaitForHITLResponse(actionButton); - await this.page.goto(`/dags/${dagId}/runs/${dagRunId}`); + await this.safeGoto(`/dags/${dagId}/runs/${dagRunId}`); await this.waitForTaskState(dagRunId, { expectedState: "Success", taskId: "valid_input_and_options" }); } @@ -118,10 +118,10 @@ export class RequiredActionsPage extends BasePage { const branchButton = this.page.getByTestId("hitl-option-task_1"); - await expect(branchButton).toBeVisible({ timeout: 10_000 }); + await expect(branchButton).toBeVisible({ timeout: 30_000 }); await this.clickButtonAndWaitForHITLResponse(branchButton); - await this.page.goto(`/dags/${dagId}/runs/${dagRunId}`); + await this.safeGoto(`/dags/${dagId}/runs/${dagRunId}`); await this.waitForTaskState(dagRunId, { expectedState: "Success", taskId: "choose_a_branch_to_run" }); } @@ -135,7 +135,7 @@ export class RequiredActionsPage extends BasePage { const informationInput = this.page.getByRole("textbox"); - await expect(informationInput).toBeVisible({ timeout: 10_000 }); + await expect(informationInput).toBeVisible({ timeout: 30_000 }); await informationInput.fill("test"); const okButton = this.page.getByRole("button", { name: "OK" }); @@ -143,7 +143,7 @@ export class RequiredActionsPage extends BasePage { await expect(okButton).toBeVisible({ timeout: 10_000 }); await this.clickButtonAndWaitForHITLResponse(okButton); - await this.page.goto(`/dags/${dagId}/runs/${dagRunId}`); + await this.safeGoto(`/dags/${dagId}/runs/${dagRunId}`); await this.waitForTaskState(dagRunId, { expectedState: "Success", taskId: "wait_for_input" }); } @@ -169,7 +169,7 @@ export class RequiredActionsPage extends BasePage { await expect(respondButton).toBeVisible({ timeout: 10_000 }); await this.clickButtonAndWaitForHITLResponse(respondButton); - await this.page.goto(`/dags/${dagId}/runs/${dagRunId}`); + await this.safeGoto(`/dags/${dagId}/runs/${dagRunId}`); await this.waitForTaskState(dagRunId, { expectedState: "Success", taskId: "wait_for_multiple_options" }); } @@ -183,10 +183,10 @@ export class RequiredActionsPage extends BasePage { const optionButton = this.page.getByTestId("hitl-option-option 1"); - await expect(optionButton).toBeVisible({ timeout: 10_000 }); + await expect(optionButton).toBeVisible({ timeout: 30_000 }); await this.clickButtonAndWaitForHITLResponse(optionButton); - await this.page.goto(`/dags/${dagId}/runs/${dagRunId}`); + await this.safeGoto(`/dags/${dagId}/runs/${dagRunId}`); await this.waitForTaskState(dagRunId, { expectedState: "Success", taskId: "wait_for_option" }); } @@ -199,13 +199,13 @@ export class RequiredActionsPage extends BasePage { throw new Error("Failed to trigger DAG - dagRunId is null"); } - await this.page.goto(`/dags/${dagId}/runs/${dagRunId}`); + await this.safeGoto(`/dags/${dagId}/runs/${dagRunId}`); await this.waitForDagRunState("Running"); await this.waitForTaskState(dagRunId, { expectedState: "Success", taskId: "wait_for_default_option", - timeout: 30_000, + timeout: 60_000, }); await this.handleWaitForInputTask(dagId, dagRunId); @@ -224,7 +224,7 @@ export class RequiredActionsPage extends BasePage { } private async verifyFinalTaskStates(dagId: string, dagRunId: string, approved: boolean): Promise { - await this.page.goto(`/dags/${dagId}/runs/${dagRunId}`); + await this.safeGoto(`/dags/${dagId}/runs/${dagRunId}`); if (approved) { await this.waitForTaskState(dagRunId, { expectedState: "Success", taskId: "task_1" }); diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts index 4fde48e7f4be1..42a8f3cae3d18 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/TaskInstancesPage.ts @@ -37,7 +37,7 @@ export class TaskInstancesPage extends BasePage { public async navigate(): Promise { await this.navigateTo(TaskInstancesPage.taskInstancesUrl); await this.page.waitForURL(/.*task_instances/, { timeout: 15_000 }); - await this.taskInstancesTable.waitFor({ state: "visible", timeout: 10_000 }); + await this.taskInstancesTable.waitFor({ state: "visible", timeout: 30_000 }); const dataLink = this.taskInstancesTable.locator("a[href*='/dags/']").first(); const noDataMessage = this.page.locator('text="No Task Instances found"'); diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts index 188a5d81fd908..becd7f286afdb 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/XComsPage.ts @@ -63,7 +63,7 @@ export class XComsPage extends BasePage { public async navigate(): Promise { await this.navigateTo(XComsPage.xcomsUrl); await this.page.waitForURL(/.*xcoms/, { timeout: 15_000 }); - await this.xcomsTable.waitFor({ state: "visible", timeout: 10_000 }); + await this.xcomsTable.waitFor({ state: "visible", timeout: 30_000 }); await this.page.waitForLoadState("networkidle"); } diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/task-instances.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/task-instances.spec.ts index 08da9fce26da4..49e2d3839e929 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/specs/task-instances.spec.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/task-instances.spec.ts @@ -19,6 +19,7 @@ import { test, expect } from "@playwright/test"; import { AUTH_FILE, testConfig } from "playwright.config"; import { TaskInstancesPage } from "tests/e2e/pages/TaskInstancesPage"; +import { waitForServerReady } from "tests/e2e/utils/health"; test.describe("Task Instances Page", () => { test.setTimeout(60_000); @@ -27,16 +28,22 @@ test.describe("Task Instances Page", () => { const testDagId = testConfig.testDag.id; test.beforeAll(async ({ browser }) => { + test.setTimeout(120_000); 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(); + // Wait for server to be responsive before making API calls + await waitForServerReady(page); + // Wait for Dag to be parsed before making API calls await expect .poll( async () => { - const response = await page.request.get(`${baseUrl}/api/v2/dags/${testDagId}`); + const response = await page.request.get(`${baseUrl}/api/v2/dags/${testDagId}`, { + timeout: 30_000, + }); return response.ok(); }, @@ -55,6 +62,7 @@ test.describe("Task Instances Page", () => { headers: { "Content-Type": "application/json", }, + timeout: 30_000, }); expect(triggerResponse1.ok()).toBeTruthy(); @@ -62,6 +70,7 @@ test.describe("Task Instances Page", () => { // Get all task instances for the first run const tasksResponse1 = await page.request.get( `${baseUrl}/api/v2/dags/${testDagId}/dagRuns/${runId1}/taskInstances`, + { timeout: 30_000 }, ); expect(tasksResponse1.ok()).toBeTruthy(); @@ -77,6 +86,7 @@ test.describe("Task Instances Page", () => { { data: JSON.stringify({ new_state: "success" }), headers: { "Content-Type": "application/json" }, + timeout: 30_000, }, ); @@ -94,6 +104,7 @@ test.describe("Task Instances Page", () => { headers: { "Content-Type": "application/json", }, + timeout: 30_000, }); expect(triggerResponse2.ok()).toBeTruthy(); @@ -101,6 +112,7 @@ test.describe("Task Instances Page", () => { // Get all task instances for the second run const tasksResponse2 = await page.request.get( `${baseUrl}/api/v2/dags/${testDagId}/dagRuns/${runId2}/taskInstances`, + { timeout: 30_000 }, ); expect(tasksResponse2.ok()).toBeTruthy(); @@ -116,6 +128,7 @@ test.describe("Task Instances Page", () => { { data: JSON.stringify({ new_state: "failed" }), headers: { "Content-Type": "application/json" }, + timeout: 30_000, }, ); diff --git a/airflow-core/src/airflow/ui/tests/e2e/utils/health.ts b/airflow-core/src/airflow/ui/tests/e2e/utils/health.ts new file mode 100644 index 0000000000000..d1d7340ac5194 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/utils/health.ts @@ -0,0 +1,67 @@ +/*! + * 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 type { Page } from "@playwright/test"; + +const HEALTH_ENDPOINT = "/api/v2/monitor/health"; +const MAX_WAIT_MS = 30_000; +const REQUEST_TIMEOUT_MS = 10_000; +const BACKOFF_INTERVALS = [1000, 2000, 4000, 8000]; + +/** + * Wait for the Airflow server to be responsive before proceeding. + * + * Polls the health endpoint, checking for HTTP 200. Uses backoff intervals between retries. + * We intentionally do not check response time: on Firefox/CI the health endpoint regularly + * exceeds 2s, which would cause the check to never succeed. + * + * When the server responds on the first attempt, this function returns immediately. + */ +export async function waitForServerReady(page: Page): Promise { + const startTime = Date.now(); + let attempt = 0; + + while (Date.now() - startTime < MAX_WAIT_MS) { + try { + const response = await page.request.get(HEALTH_ENDPOINT, { + timeout: REQUEST_TIMEOUT_MS, + }); + + if (response.status() === 200) { + return; + } + } catch { + // Request failed or timed out — server not ready yet. + } + + const index = Math.min(attempt, BACKOFF_INTERVALS.length - 1); + const interval = BACKOFF_INTERVALS[index] as number; + const remaining = MAX_WAIT_MS - (Date.now() - startTime); + + if (remaining <= 0) { + break; + } + + await page.waitForTimeout(Math.min(interval, remaining)); + attempt++; + } + + throw new Error( + `Server not ready after ${MAX_WAIT_MS}ms — health endpoint ${HEALTH_ENDPOINT} did not return 200`, + ); +}