This is an automated email from the ASF dual-hosted git repository.
rahulvats pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 28f7cf81b1b E2E: Add health-aware navigation to reduce false-positive
failures (#64366)
28f7cf81b1b is described below
commit 28f7cf81b1b762cf1a32127833925edd67ddb7aa
Author: Dev-iL <[email protected]>
AuthorDate: Sun Mar 29 15:40:59 2026 +0300
E2E: Add health-aware navigation to reduce false-positive failures (#64366)
* E2E: Add health-aware navigation to eliminate false-positive test failures
Tests running with 2 parallel workers produce cascading failures when one
heavy test (HITL, backfill) overwhelms the shared Airflow server, causing
all concurrent navigations to timeout. Retries also fail because the
server hasn't recovered.
Add a health-check utility that polls /api/v2/monitor/health before every
navigation, using backoff intervals [1s, 2s, 4s, 8s] with a 60s cap. When
the server is responsive (the common case), the check returns immediately
with negligible overhead. When overloaded, it waits for recovery instead
of blindly navigating into timeouts.
The health check is centralized in a new `BasePage.goto()` method that all
page objects inherit. 12 direct `page.goto()` calls across DagsPage,
RequiredActionsPage, DagCalendarTab, and EventsPage now use `this.goto()`
instead. No spec files modified, no timeouts increased.
* E2E: Rename BasePage.goto() to safeGoto() to fix stack overflow
AssetDetailPage defines its own public goto() method that calls
this.navigateTo(). When BasePage.navigateTo() dispatched to this.goto(),
polymorphism resolved to AssetDetailPage.goto() instead of BasePage.goto(),
creating infinite recursion.
Rename to safeGoto() which doesn't collide with any subclass method names.
* E2E: Relax health check to fix Firefox test timeouts
Remove the 2000ms response time threshold from health endpoint checks.
The threshold was too strict for Firefox on CI, causing tests to timeout
while waiting for server readiness even though the server was healthy
(returning 200). The health check should verify the server responds, not
enforce a specific response time.
Increases REQUEST_TIMEOUT_MS to 10000ms for individual request attempts
to give slower environments time to respond, while keeping the overall
MAX_WAIT_MS at 60s.
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
* E2E: Increase element visibility timeouts for Firefox CI flakiness
The health-aware safeGoto() protects against an unresponsive server,
but two Firefox-only flaky failures occur after navigation succeeds:
click-based navigation (RequiredActionsPage link clicks) and
post-navigation React rendering (TaskInstancesPage table) can exceed
10s on Firefox under CI load. Increase these element visibility
timeouts from 10s to 30s, matching the threshold already used by
handleWaitForMultipleOptionsTask.
* E2E: Fix webkit-specific flakiness across 5 page objects
- ConnectionsPage: combobox click timeout 3s→10s (matching actionTimeout)
- DagCalendarTab: wrap hover+tooltip in retry loop — webkit hover events
are unreliable and may not trigger tooltips on first attempt
- DagCodePage: inner Monaco editor visibility check 5s→10s per retry
attempt, giving the editor more time to initialize on webkit
- RequiredActionsPage: wait_for_default_option toPass timeout 30s→60s,
too tight on webkit under 3-browser CI load
- XComsPage: table visibility wait 10s→30s, same pattern as
TaskInstancesPage fix
* E2E: Rebalance health check and test timeouts
The 60s health check exceeded the 30s default test timeout, causing
tests to die while the health check was still polling (plugins.spec.ts).
It also consumed the entire toPass budget (triggerDag has toPass 60s),
leaving no room for retries after the health check exhausted the
timeout.
- Reduce health check MAX_WAIT_MS from 60s to 30s
- Increase default test timeout from 30s to 60s
This ensures the health check fits within any test's time budget
(30s < 60s), leaving 30s for navigation and assertions. Heavy tests
with custom timeouts (120s+) are unaffected.
* E2E: Robustify task-instances spec setup against server overload
The beforeAll makes many direct API calls (POST dagRuns, GET
taskInstances, PATCH state) that use the global 10s actionTimeout.
Under CI load these time out. Also, the setup had no health check
and inherited the 60s describe-level timeout for a multi-step setup.
- Add waitForServerReady before API calls
- Increase beforeAll timeout to 120s
- Add explicit 30s timeout to all API calls (3x the actionTimeout)
---------
Co-authored-by: Claude Haiku 4.5 <[email protected]>
---
airflow-core/src/airflow/ui/playwright.config.ts | 2 +-
.../src/airflow/ui/tests/e2e/pages/BasePage.ts | 11 +++-
.../airflow/ui/tests/e2e/pages/ConnectionsPage.ts | 2 +-
.../airflow/ui/tests/e2e/pages/DagCalendarTab.ts | 11 ++--
.../src/airflow/ui/tests/e2e/pages/DagCodePage.ts | 2 +-
.../src/airflow/ui/tests/e2e/pages/DagsPage.ts | 6 +-
.../src/airflow/ui/tests/e2e/pages/EventsPage.ts | 2 +-
.../ui/tests/e2e/pages/RequiredActionsPage.ts | 24 ++++----
.../ui/tests/e2e/pages/TaskInstancesPage.ts | 2 +-
.../src/airflow/ui/tests/e2e/pages/XComsPage.ts | 2 +-
.../ui/tests/e2e/specs/task-instances.spec.ts | 15 ++++-
.../src/airflow/ui/tests/e2e/utils/health.ts | 67 ++++++++++++++++++++++
12 files changed, 117 insertions(+), 29 deletions(-)
diff --git a/airflow-core/src/airflow/ui/playwright.config.ts
b/airflow-core/src/airflow/ui/playwright.config.ts
index c6ec3b76170..96373d2a097 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 44a897e466c..968b62b1e73 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<void> {
- 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<Page["goto"]>[1]): Promise<void> {
+ 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 31a1dfe7f94..cf43c60195d 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 dcf1d6dc7dc..7aee20db7c0 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 fbb2f27ab59..b45bb4dd88d 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<void> {
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 d383593ab16..4b9a5a65d28 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<void> {
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<void> {
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 d9bbf727420..5c72b431311 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<void> {
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 fc08d6b3a91..db04ec56cc9 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<void> {
- 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 4fde48e7f4b..42a8f3cae3d 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<void> {
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 188a5d81fd9..becd7f286af 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<void> {
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 08da9fce26d..49e2d3839e9 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 00000000000..d1d7340ac51
--- /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<void> {
+ 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`,
+ );
+}