diff --git a/airflow-core/src/airflow/ui/playwright.config.ts b/airflow-core/src/airflow/ui/playwright.config.ts index 36351c3bc8663..647b2d3fda19c 100644 --- a/airflow-core/src/airflow/ui/playwright.config.ts +++ b/airflow-core/src/airflow/ui/playwright.config.ts @@ -50,11 +50,12 @@ export const AUTH_FILE = path.join(currentDirname, "playwright/.auth/user.json") export default defineConfig({ expect: { - timeout: 5000, + timeout: 15_000, }, forbidOnly: process.env.CI !== undefined && process.env.CI !== "", fullyParallel: true, globalSetup: "./tests/e2e/global-setup.ts", + globalTeardown: "./tests/e2e/global-teardown.ts", projects: [ { name: "chromium", @@ -109,20 +110,10 @@ export default defineConfig({ retries: process.env.CI !== undefined && process.env.CI !== "" ? 2 : 0, testDir: "./tests/e2e/specs", - // TODO: Temporarily ignore flaky specs until stabilized - // See: #63036 - testIgnore: [ - "**/dag-runs-tab.spec.ts", - "**/dag-runs.spec.ts", - "**/dag-grid-view.spec.ts", - "**/task-logs.spec.ts", - "**/dag-tasks.spec.ts", - "**/variable.spec.ts", - ], - timeout: 30_000, + timeout: 60_000, use: { - actionTimeout: 10_000, + actionTimeout: 15_000, baseURL: process.env.AIRFLOW_UI_BASE_URL ?? "http://localhost:28080", screenshot: "only-on-failure", trace: "on-first-retry", diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/asset-data.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/asset-data.ts new file mode 100644 index 0000000000000..13ed633266eea --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/asset-data.ts @@ -0,0 +1,65 @@ +/*! + * 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. + */ + +/** + * Asset data fixture — triggers asset_produces_1 DAG and waits for success. + */ +import { test as base } from "tests/e2e/fixtures"; +import { + safeCleanupDagRun, + apiTriggerDagRun, + waitForDagReady, + waitForDagRunStatus, +} from "tests/e2e/utils/test-helpers"; + +export type AssetData = { + dagId: string; +}; + +export const test = base.extend, { assetData: AssetData }>({ + assetData: [ + async ({ authenticatedRequest }, use, _workerInfo) => { + const dagId = "asset_produces_1"; + let createdRunId: string | undefined; + + try { + await waitForDagReady(authenticatedRequest, dagId); + await authenticatedRequest.patch(`/api/v2/dags/${dagId}`, { data: { is_paused: false } }); + const { dagRunId } = await apiTriggerDagRun(authenticatedRequest, dagId); + + createdRunId = dagRunId; + + await waitForDagRunStatus(authenticatedRequest, { + dagId, + expectedState: "success", + runId: dagRunId, + timeout: 120_000, + }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use({ dagId }); + } finally { + if (createdRunId !== undefined) { + await safeCleanupDagRun(authenticatedRequest, dagId, createdRunId); + } + } + }, + { scope: "worker", timeout: 180_000 }, + ], +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/audit-log-data.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/audit-log-data.ts new file mode 100644 index 0000000000000..54cb9f5982b5e --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/audit-log-data.ts @@ -0,0 +1,68 @@ +/*! + * 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. + */ + +/** + * Audit log data fixture — triggers DAG runs to generate audit log entries. + */ +import { testConfig } from "playwright.config"; +import { test as base } from "tests/e2e/fixtures"; +import { + safeCleanupDagRun, + apiTriggerDagRun, + waitForDagReady, + waitForDagRunStatus, +} from "tests/e2e/utils/test-helpers"; + +export type AuditLogData = { + dagId: string; +}; + +export const test = base.extend, { auditLogData: AuditLogData }>({ + auditLogData: [ + async ({ authenticatedRequest }, use, _workerInfo) => { + const dagId = testConfig.testDag.id; + const createdRunIds: Array = []; + + try { + await waitForDagReady(authenticatedRequest, dagId); + await authenticatedRequest.patch(`/api/v2/dags/${dagId}`, { data: { is_paused: false } }); + + for (let i = 0; i < 3; i++) { + const { dagRunId } = await apiTriggerDagRun(authenticatedRequest, dagId); + + createdRunIds.push(dagRunId); + await waitForDagRunStatus(authenticatedRequest, { + dagId, + expectedState: "success", + runId: dagRunId, + timeout: 60_000, + }); + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use({ dagId }); + } finally { + for (const runId of createdRunIds) { + await safeCleanupDagRun(authenticatedRequest, dagId, runId); + } + } + }, + { scope: "worker", timeout: 180_000 }, + ], +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/calendar-data.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/calendar-data.ts new file mode 100644 index 0000000000000..43f3201b52ea6 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/calendar-data.ts @@ -0,0 +1,88 @@ +/*! + * 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. + */ + +/** + * Calendar data fixture — creates success + failed DAG runs for calendar tests. + */ +import dayjs from "dayjs"; +import { testConfig } from "playwright.config"; +import { test as base } from "tests/e2e/fixtures"; +import { + apiCreateDagRun, + safeCleanupDagRun, + apiSetDagRunState, + uniqueRunId, + waitForDagReady, +} from "tests/e2e/utils/test-helpers"; + +export type CalendarRunsData = { + dagId: string; +}; + +export const test = base.extend, { calendarRunsData: CalendarRunsData }>({ + calendarRunsData: [ + async ({ authenticatedRequest }, use, workerInfo) => { + const dagId = testConfig.testDag.id; + const createdRunIds: Array = []; + + await waitForDagReady(authenticatedRequest, dagId); + + const now = dayjs(); + const yesterday = now.subtract(1, "day"); + const baseDate = yesterday.isSame(now, "month") ? yesterday : now; + + const workerHourOffset = workerInfo.parallelIndex * 2; + const successIso = baseDate + .startOf("day") + .hour(2 + workerHourOffset) + .toISOString(); + const failedIso = baseDate + .startOf("day") + .hour(3 + workerHourOffset) + .toISOString(); + + const successRunId = uniqueRunId("cal_success"); + const failedRunId = uniqueRunId("cal_failed"); + + try { + await apiCreateDagRun(authenticatedRequest, dagId, { + dag_run_id: successRunId, + logical_date: successIso, + }); + createdRunIds.push(successRunId); + await apiSetDagRunState(authenticatedRequest, { dagId, runId: successRunId, state: "success" }); + + await apiCreateDagRun(authenticatedRequest, dagId, { + dag_run_id: failedRunId, + logical_date: failedIso, + }); + createdRunIds.push(failedRunId); + await apiSetDagRunState(authenticatedRequest, { dagId, runId: failedRunId, state: "failed" }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use({ dagId }); + } finally { + for (const runId of createdRunIds) { + await safeCleanupDagRun(authenticatedRequest, dagId, runId); + } + } + }, + { scope: "worker", timeout: 180_000 }, + ], +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/dag-runs-data.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/dag-runs-data.ts new file mode 100644 index 0000000000000..0aea052a04577 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/dag-runs-data.ts @@ -0,0 +1,90 @@ +/*! + * 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. + */ + +/** + * DAG Runs page data fixture — creates runs across two DAGs for filtering tests. + */ +import { testConfig } from "playwright.config"; +import { test as base } from "tests/e2e/fixtures"; +import { + apiCreateDagRun, + safeCleanupDagRun, + apiSetDagRunState, + uniqueRunId, + waitForDagReady, +} from "tests/e2e/utils/test-helpers"; + +export type DagRunsPageData = { + dag1Id: string; + dag2Id: string; +}; + +export const test = base.extend, { dagRunsPageData: DagRunsPageData }>({ + dagRunsPageData: [ + async ({ authenticatedRequest }, use, workerInfo) => { + const dag1Id = testConfig.testDag.id; + const dag2Id = "example_python_operator"; + const createdRuns: Array<{ dagId: string; runId: string }> = []; + + try { + await Promise.all([ + waitForDagReady(authenticatedRequest, dag1Id), + waitForDagReady(authenticatedRequest, dag2Id), + ]); + + const baseOffset = workerInfo.parallelIndex * 7_200_000; + const timestamp = Date.now() - baseOffset; + + const runId1 = uniqueRunId("dagrun_failed"); + + await apiCreateDagRun(authenticatedRequest, dag1Id, { + dag_run_id: runId1, + logical_date: new Date(timestamp).toISOString(), + }); + createdRuns.push({ dagId: dag1Id, runId: runId1 }); + await apiSetDagRunState(authenticatedRequest, { dagId: dag1Id, runId: runId1, state: "failed" }); + + const runId2 = uniqueRunId("dagrun_success"); + + await apiCreateDagRun(authenticatedRequest, dag1Id, { + dag_run_id: runId2, + logical_date: new Date(timestamp + 60_000).toISOString(), + }); + createdRuns.push({ dagId: dag1Id, runId: runId2 }); + await apiSetDagRunState(authenticatedRequest, { dagId: dag1Id, runId: runId2, state: "success" }); + + const runId3 = uniqueRunId("dagrun_other"); + + await apiCreateDagRun(authenticatedRequest, dag2Id, { + dag_run_id: runId3, + logical_date: new Date(timestamp + 120_000).toISOString(), + }); + createdRuns.push({ dagId: dag2Id, runId: runId3 }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use({ dag1Id, dag2Id }); + } finally { + for (const { dagId, runId } of createdRuns) { + await safeCleanupDagRun(authenticatedRequest, dagId, runId); + } + } + }, + { scope: "worker", timeout: 120_000 }, + ], +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/dashboard-data.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/dashboard-data.ts new file mode 100644 index 0000000000000..f4431a2984ec9 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/dashboard-data.ts @@ -0,0 +1,42 @@ +/*! + * 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. + */ + +/** + * Dashboard data fixture — tracks UI-triggered DAG runs for cleanup. + */ +import { testConfig } from "playwright.config"; +import { test as base } from "tests/e2e/fixtures"; +import { safeCleanupDagRun } from "tests/e2e/utils/test-helpers"; + +export type DagRunCleanup = { + track: (runId: string) => void; +}; + +/* eslint-disable react-hooks/rules-of-hooks -- Playwright's `use` is not a React Hook. */ +export const test = base.extend<{ dagRunCleanup: DagRunCleanup }>({ + dagRunCleanup: async ({ authenticatedRequest }, use) => { + const trackedRunIds: Array = []; + + await use({ track: (runId: string) => trackedRunIds.push(runId) }); + + for (const runId of trackedRunIds) { + await safeCleanupDagRun(authenticatedRequest, testConfig.testDag.id, runId); + } + }, +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/data.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/data.ts new file mode 100644 index 0000000000000..cc2c1239fd5ed --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/data.ts @@ -0,0 +1,211 @@ +/*! + * 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. + */ + +/** + * Data-isolation fixtures for E2E tests. + * + * Extends the POM fixtures with worker-scoped and test-scoped data + * fixtures that create API resources and guarantee cleanup — even on + * test failure, timeout, or retry. + */ +import type { APIRequestContext } from "@playwright/test"; + +import { testConfig } from "../../../playwright.config"; +import { + apiCreateDagRun, + apiSetDagRunState, + apiTriggerDagRun, + safeCleanupDagRun, + uniqueRunId, + waitForDagReady, + waitForDagRunStatus, +} from "../utils/test-helpers"; +import { test as base } from "./pom"; + +/** Shape returned by single DAG run fixtures. */ +export type DagRunFixtureData = { + dagId: string; + logicalDate: string; + runId: string; +}; + +/** Shape returned by the successAndFailedRuns fixture. */ +export type SuccessAndFailedRunsData = { + dagId: string; + failedRun: DagRunFixtureData; + successRun: DagRunFixtureData; +}; + +export type DataWorkerFixtures = { + /** Ensures the default test DAG is parsed and ready. Worker-scoped, no cleanup needed. */ + dagReady: string; + /** A DAG run triggered via scheduler and completed. Worker-scoped with auto-cleanup. */ + executedDagRun: DagRunFixtureData; + /** Two DAG runs: one success, one failed. Worker-scoped with auto-cleanup. */ + successAndFailedRuns: SuccessAndFailedRunsData; + /** A DAG run in "success" state (API-only, no scheduler). Worker-scoped with auto-cleanup. */ + successDagRun: DagRunFixtureData; +}; + +export type DataTestFixtures = Record; + +async function createAndSetupDagRun( + request: APIRequestContext, + dagId: string, + options: { + logicalDate?: string; + parallelIndex: number; + prefix: string; + state: "failed" | "queued" | "success"; + }, +): Promise { + await waitForDagReady(request, dagId); + + const runId = uniqueRunId(`${options.prefix}_w${options.parallelIndex}`); + + // Offset logical_date by 2 hours per worker to avoid collisions. + const offsetMs = options.parallelIndex * 7_200_000; + const logicalDate = options.logicalDate ?? new Date(Date.now() - offsetMs).toISOString(); + + await apiCreateDagRun(request, dagId, { + dag_run_id: runId, + logical_date: logicalDate, + }); + await apiSetDagRunState(request, { dagId, runId, state: options.state }); + + return { dagId, logicalDate, runId }; +} + +async function cleanupMultipleRuns( + request: APIRequestContext, + runs: Array<{ dagId: string; runId: string }>, +): Promise { + for (const { dagId, runId } of runs) { + await safeCleanupDagRun(request, dagId, runId); + } +} + +export const test = base.extend({ + dagReady: [ + async ({ authenticatedRequest }, use) => { + const dagId = testConfig.testDag.id; + + await waitForDagReady(authenticatedRequest, dagId); + await use(dagId); + }, + { scope: "worker", timeout: 120_000 }, + ], + + executedDagRun: [ + async ({ authenticatedRequest }, use) => { + const dagId = testConfig.testDag.id; + let dagRunId: string | undefined; + + try { + await waitForDagReady(authenticatedRequest, dagId); + await authenticatedRequest.patch(`/api/v2/dags/${dagId}`, { + data: { is_paused: false }, + }); + + const triggered = await apiTriggerDagRun(authenticatedRequest, dagId); + + ({ dagRunId } = triggered); + + await waitForDagRunStatus(authenticatedRequest, { + dagId, + expectedState: "success", + runId: dagRunId, + timeout: 120_000, + }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use({ dagId, logicalDate: triggered.logicalDate, runId: dagRunId }); + } finally { + if (dagRunId !== undefined) { + await safeCleanupDagRun(authenticatedRequest, dagId, dagRunId); + } + // Re-pause is handled by global-teardown.ts (globalTeardown in playwright.config.ts). + } + }, + { scope: "worker", timeout: 180_000 }, + ], + + successAndFailedRuns: [ + async ({ authenticatedRequest }, use, workerInfo) => { + const dagId = testConfig.testDag.id; + const createdRuns: Array<{ dagId: string; runId: string }> = []; + + try { + await waitForDagReady(authenticatedRequest, dagId); + + const baseOffset = workerInfo.parallelIndex * 7_200_000; + const timestamp = Date.now() - baseOffset; + + const successRun = await createAndSetupDagRun(authenticatedRequest, dagId, { + logicalDate: new Date(timestamp).toISOString(), + parallelIndex: workerInfo.parallelIndex, + prefix: "sf_ok", + state: "success", + }); + + createdRuns.push({ dagId, runId: successRun.runId }); + + const failedRun = await createAndSetupDagRun(authenticatedRequest, dagId, { + logicalDate: new Date(timestamp + 60_000).toISOString(), + parallelIndex: workerInfo.parallelIndex, + prefix: "sf_fail", + state: "failed", + }); + + createdRuns.push({ dagId, runId: failedRun.runId }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use({ dagId, failedRun, successRun }); + } finally { + await cleanupMultipleRuns(authenticatedRequest, createdRuns); + } + }, + { scope: "worker", timeout: 120_000 }, + ], + + successDagRun: [ + async ({ authenticatedRequest }, use, workerInfo) => { + const dagId = testConfig.testDag.id; + let createdRunId: string | undefined; + + try { + const data = await createAndSetupDagRun(authenticatedRequest, dagId, { + parallelIndex: workerInfo.parallelIndex, + prefix: "run", + state: "success", + }); + + createdRunId = data.runId; + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use(data); + } finally { + if (createdRunId !== undefined) { + await safeCleanupDagRun(authenticatedRequest, dagId, createdRunId); + } + } + }, + { scope: "worker", timeout: 120_000 }, + ], +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/index.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/index.ts new file mode 100644 index 0000000000000..bef87ad21a53c --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/index.ts @@ -0,0 +1,30 @@ +/*! + * 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. + */ + +/** + * Unified fixture entry point for E2E tests. + * + * Re-exports the fully extended `test` object (POM + data fixtures) + * so specs only need one import: + * + * import { expect, test } from "tests/e2e/fixtures"; + */ +export { test } from "./data"; +export type { DagRunFixtureData, SuccessAndFailedRunsData } from "./data"; +export { expect } from "@playwright/test"; diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/pom.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/pom.ts new file mode 100644 index 0000000000000..d561be27faad5 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/pom.ts @@ -0,0 +1,156 @@ +/*! + * 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. + */ + +/** + * Page Object Model fixtures for E2E tests. + * + * Provides POM instances and the worker-scoped authenticatedRequest + * as test fixtures, eliminating manual `beforeEach` boilerplate. + */ +import { test as base, type APIRequestContext } from "@playwright/test"; + +import { AUTH_FILE, testConfig } from "../../../playwright.config"; +import { AssetDetailPage } from "../pages/AssetDetailPage"; +import { AssetListPage } from "../pages/AssetListPage"; +import { BackfillPage } from "../pages/BackfillPage"; +import { ConfigurationPage } from "../pages/ConfigurationPage"; +import { ConnectionsPage } from "../pages/ConnectionsPage"; +import { DagCalendarTab } from "../pages/DagCalendarTab"; +import { DagCodePage } from "../pages/DagCodePage"; +import { DagRunsPage } from "../pages/DagRunsPage"; +import { DagRunsTabPage } from "../pages/DagRunsTabPage"; +import { DagsPage } from "../pages/DagsPage"; +import { EventsPage } from "../pages/EventsPage"; +import { GridPage } from "../pages/GridPage"; +import { HomePage } from "../pages/HomePage"; +import { PluginsPage } from "../pages/PluginsPage"; +import { PoolsPage } from "../pages/PoolsPage"; +import { ProvidersPage } from "../pages/ProvidersPage"; +import { RequiredActionsPage } from "../pages/RequiredActionsPage"; +import { TaskInstancePage } from "../pages/TaskInstancePage"; +import { TaskInstancesPage } from "../pages/TaskInstancesPage"; +import { VariablePage } from "../pages/VariablePage"; +import { XComsPage } from "../pages/XComsPage"; + +export type PomWorkerFixtures = { + authenticatedRequest: APIRequestContext; +}; + +export type PomFixtures = { + assetDetailPage: AssetDetailPage; + assetListPage: AssetListPage; + backfillPage: BackfillPage; + configurationPage: ConfigurationPage; + connectionsPage: ConnectionsPage; + dagCalendarTab: DagCalendarTab; + dagCodePage: DagCodePage; + dagRunsPage: DagRunsPage; + dagRunsTabPage: DagRunsTabPage; + dagsPage: DagsPage; + eventsPage: EventsPage; + gridPage: GridPage; + homePage: HomePage; + pluginsPage: PluginsPage; + poolsPage: PoolsPage; + providersPage: ProvidersPage; + requiredActionsPage: RequiredActionsPage; + taskInstancePage: TaskInstancePage; + taskInstancesPage: TaskInstancesPage; + variablePage: VariablePage; + xcomsPage: XComsPage; +}; + +/* eslint-disable react-hooks/rules-of-hooks -- Playwright's `use` is not a React Hook. */ +export const test = base.extend({ + assetDetailPage: async ({ page }, use) => { + await use(new AssetDetailPage(page)); + }, + assetListPage: async ({ page }, use) => { + await use(new AssetListPage(page)); + }, + authenticatedRequest: [ + async ({ playwright }, use) => { + const ctx = await playwright.request.newContext({ + baseURL: testConfig.connection.baseUrl, + storageState: AUTH_FILE, + }); + + await use(ctx); + await ctx.dispose(); + }, + { scope: "worker" }, + ], + backfillPage: async ({ page }, use) => { + await use(new BackfillPage(page)); + }, + configurationPage: async ({ page }, use) => { + await use(new ConfigurationPage(page)); + }, + connectionsPage: async ({ page }, use) => { + await use(new ConnectionsPage(page)); + }, + dagCalendarTab: async ({ page }, use) => { + await use(new DagCalendarTab(page)); + }, + dagCodePage: async ({ page }, use) => { + await use(new DagCodePage(page)); + }, + dagRunsPage: async ({ page }, use) => { + await use(new DagRunsPage(page)); + }, + dagRunsTabPage: async ({ page }, use) => { + await use(new DagRunsTabPage(page)); + }, + dagsPage: async ({ page }, use) => { + await use(new DagsPage(page)); + }, + eventsPage: async ({ page }, use) => { + await use(new EventsPage(page)); + }, + gridPage: async ({ page }, use) => { + await use(new GridPage(page)); + }, + homePage: async ({ page }, use) => { + await use(new HomePage(page)); + }, + pluginsPage: async ({ page }, use) => { + await use(new PluginsPage(page)); + }, + poolsPage: async ({ page }, use) => { + await use(new PoolsPage(page)); + }, + providersPage: async ({ page }, use) => { + await use(new ProvidersPage(page)); + }, + requiredActionsPage: async ({ page }, use) => { + await use(new RequiredActionsPage(page)); + }, + taskInstancePage: async ({ page }, use) => { + await use(new TaskInstancePage(page)); + }, + taskInstancesPage: async ({ page }, use) => { + await use(new TaskInstancesPage(page)); + }, + variablePage: async ({ page }, use) => { + await use(new VariablePage(page)); + }, + xcomsPage: async ({ page }, use) => { + await use(new XComsPage(page)); + }, +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/task-instances-data.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/task-instances-data.ts new file mode 100644 index 0000000000000..b212b2c888458 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/task-instances-data.ts @@ -0,0 +1,111 @@ +/*! + * 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. + */ + +/** + * Task instances data fixture — creates runs with success/failed task instances. + */ +import { expect, type APIRequestContext } from "@playwright/test"; +import { testConfig } from "playwright.config"; +import { test as base } from "tests/e2e/fixtures"; +import { + apiCreateDagRun, + safeCleanupDagRun, + uniqueRunId, + waitForDagReady, +} from "tests/e2e/utils/test-helpers"; + +export type TaskInstancesData = { + dagId: string; +}; + +async function setAllTaskInstanceStates( + request: APIRequestContext, + options: { dagId: string; runId: string; state: string }, +): Promise { + const { dagId, runId, state } = options; + + const tasksResponse = await request.get(`/api/v2/dags/${dagId}/dagRuns/${runId}/taskInstances`); + + expect(tasksResponse.ok()).toBeTruthy(); + + const tasksData = (await tasksResponse.json()) as { + task_instances: Array<{ task_id: string }>; + }; + + for (const task of tasksData.task_instances) { + await expect + .poll( + async () => { + const resp = await request.patch( + `/api/v2/dags/${dagId}/dagRuns/${runId}/taskInstances/${task.task_id}`, + { + data: { new_state: state }, + headers: { "Content-Type": "application/json" }, + timeout: 30_000, + }, + ); + + return resp.ok(); + }, + { intervals: [2000], timeout: 30_000 }, + ) + .toBe(true); + } +} + +export const test = base.extend, { taskInstancesData: TaskInstancesData }>({ + taskInstancesData: [ + async ({ authenticatedRequest }, use, workerInfo) => { + const dagId = testConfig.testDag.id; + const createdRunIds: Array = []; + const baseOffset = workerInfo.parallelIndex * 7_200_000; + const timestamp = Date.now() - baseOffset; + + try { + await waitForDagReady(authenticatedRequest, dagId); + + const runId1 = uniqueRunId("ti_success"); + + await apiCreateDagRun(authenticatedRequest, dagId, { + dag_run_id: runId1, + logical_date: new Date(timestamp).toISOString(), + }); + createdRunIds.push(runId1); + await setAllTaskInstanceStates(authenticatedRequest, { dagId, runId: runId1, state: "success" }); + + const runId2 = uniqueRunId("ti_failed"); + + await apiCreateDagRun(authenticatedRequest, dagId, { + dag_run_id: runId2, + logical_date: new Date(timestamp + 60_000).toISOString(), + }); + createdRunIds.push(runId2); + await setAllTaskInstanceStates(authenticatedRequest, { dagId, runId: runId2, state: "failed" }); + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use({ dagId }); + } finally { + for (const runId of createdRunIds) { + await safeCleanupDagRun(authenticatedRequest, dagId, runId); + } + } + }, + { scope: "worker", timeout: 120_000 }, + ], +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/fixtures/xcom-data.ts b/airflow-core/src/airflow/ui/tests/e2e/fixtures/xcom-data.ts new file mode 100644 index 0000000000000..5942d2aaec178 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/fixtures/xcom-data.ts @@ -0,0 +1,78 @@ +/*! + * 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. + */ + +/** + * XCom data fixture — triggers example_xcom DAG runs to generate XCom entries. + */ +import { testConfig } from "playwright.config"; +import { test as base } from "tests/e2e/fixtures"; +import { + apiCreateDagRun, + safeCleanupDagRun, + uniqueRunId, + waitForDagReady, + waitForDagRunStatus, +} from "tests/e2e/utils/test-helpers"; + +export type XcomRunsData = { + dagId: string; + xcomKey: string; +}; + +export const test = base.extend, { xcomRunsData: XcomRunsData }>({ + xcomRunsData: [ + async ({ authenticatedRequest }, use, workerInfo) => { + const dagId = testConfig.xcomDag.id; + const createdRunIds: Array = []; + const triggerCount = 2; + const baseOffset = workerInfo.parallelIndex * 7_200_000; + + try { + await waitForDagReady(authenticatedRequest, dagId); + await authenticatedRequest.patch(`/api/v2/dags/${dagId}`, { + data: { is_paused: false }, + }); + + for (let i = 0; i < triggerCount; i++) { + const runId = uniqueRunId(`xcom_run_${i}`); + + await apiCreateDagRun(authenticatedRequest, dagId, { + dag_run_id: runId, + logical_date: new Date(Date.now() - baseOffset + i * 60_000).toISOString(), + }); + createdRunIds.push(runId); + await waitForDagRunStatus(authenticatedRequest, { + dagId, + expectedState: "success", + runId, + timeout: 120_000, + }); + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + await use({ dagId, xcomKey: "return_value" }); + } finally { + for (const runId of createdRunIds) { + await safeCleanupDagRun(authenticatedRequest, dagId, runId); + } + } + }, + { scope: "worker", timeout: 180_000 }, + ], +}); diff --git a/airflow-core/src/airflow/ui/tests/e2e/global-teardown.ts b/airflow-core/src/airflow/ui/tests/e2e/global-teardown.ts new file mode 100644 index 0000000000000..aae6cc588aa5f --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/global-teardown.ts @@ -0,0 +1,60 @@ +/*! + * 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 { request } from "@playwright/test"; + +import { AUTH_FILE, testConfig } from "../../playwright.config"; + +/** + * Re-pause all DAGs that E2E fixtures may have unpaused during the test run. + * Runs once after all workers have finished, preventing the scheduler from + * creating unbounded DAG runs in shared environments. + */ +async function globalTeardown() { + const baseURL = testConfig.connection.baseUrl; + + const context = await request.newContext({ + baseURL, + storageState: AUTH_FILE, + }); + + const dagIds = [ + testConfig.testDag.id, + testConfig.testDag.hitlId, + testConfig.xcomDag.id, + "asset_produces_1", + ]; + + for (const dagId of dagIds) { + try { + await context.patch(`/api/v2/dags/${dagId}`, { + data: { is_paused: true }, + headers: { "Content-Type": "application/json" }, + timeout: 10_000, + }); + } catch (error) { + console.warn( + `[e2e teardown] Failed to re-pause DAG ${dagId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + await context.dispose(); +} + +export default globalTeardown; diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/AssetDetailPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/AssetDetailPage.ts index 964774e6a803f..ecd414fd6efa6 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/AssetDetailPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/AssetDetailPage.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; import { BasePage } from "./BasePage"; @@ -33,12 +33,12 @@ export class AssetDetailPage extends BasePage { await this.page.getByRole("link", { exact: true, name }).click(); } - public async goto(): Promise { - await this.navigateTo(AssetDetailPage.url); + public getHeading(name: string): Locator { + return this.page.getByRole("heading", { name }); } - public async verifyAssetDetails(name: string): Promise { - await expect(this.page.getByRole("heading", { name })).toBeVisible(); + public async goto(): Promise { + await this.navigateTo(AssetDetailPage.url); } public async verifyProducingTasks(): Promise { 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..704eb6ad97ce3 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts @@ -18,6 +18,7 @@ */ import { expect, type Locator, type Page } from "@playwright/test"; import { BasePage } from "tests/e2e/pages/BasePage"; +import { waitForStableRowCount } from "tests/e2e/utils/test-helpers"; type ConnectionDetails = { conn_type: string; @@ -32,7 +33,6 @@ type ConnectionDetails = { }; export class ConnectionsPage extends BasePage { - // Page URLs public static get connectionsListUrl(): string { return "/connections"; } @@ -42,10 +42,8 @@ export class ConnectionsPage extends BasePage { public readonly connectionForm: Locator; public readonly connectionIdHeader: Locator; public readonly connectionIdInput: Locator; - // Core page elements public readonly connectionsTable: Locator; public readonly connectionTypeHeader: Locator; - public readonly connectionTypeSelect: Locator; public readonly descriptionInput: Locator; public readonly emptyState: Locator; public readonly hostHeader: Locator; @@ -60,58 +58,51 @@ export class ConnectionsPage extends BasePage { public readonly schemaInput: Locator; public readonly searchInput: Locator; public readonly successAlert: Locator; - // Sorting and filtering public readonly tableHeader: Locator; public readonly testConnectionButton: Locator; public constructor(page: Page) { super(page); - // Table elements (Chakra UI DataTable) - this.connectionsTable = page.locator('[role="grid"], table'); - this.emptyState = page.locator("text=/No connection found!/i"); + this.connectionsTable = page.getByRole("grid").or(page.locator("table")); + this.emptyState = page.getByText(/no connection found/i); - // Action buttons this.addButton = page.getByRole("button", { name: "Add Connection" }); - this.testConnectionButton = page.locator('button:has-text("Test")'); + this.testConnectionButton = page.getByRole("button", { name: /^test$/i }); this.saveButton = page.getByRole("button", { name: /^save$/i }); - // Form inputs (Chakra UI inputs) - this.connectionForm = page.locator('[data-scope="dialog"][data-part="content"]'); + // Scoped via input[name] because Chakra UI forms may lack + // associated