diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/PluginsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/PluginsPage.ts new file mode 100644 index 0000000000000..cff8d4b161856 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/PluginsPage.ts @@ -0,0 +1,127 @@ +/*! + * 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 { Locator, Page } from "@playwright/test"; + +import { BasePage } from "./BasePage"; + +/** + * Plugins Page Object + */ +export class PluginsPage extends BasePage { + public readonly emptyState: Locator; + public readonly heading: Locator; + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + public readonly rows: Locator; + public readonly table: Locator; + + public constructor(page: Page) { + super(page); + + this.heading = page.getByRole("heading", { + name: /plugins/i, + }); + this.table = page.getByTestId("table-list"); + this.rows = this.table.locator("tbody tr").filter({ + has: page.locator("td"), + }); + this.paginationNextButton = page.locator('[data-testid="next"]'); + this.paginationPrevButton = page.locator('[data-testid="prev"]'); + this.emptyState = page.getByText(/no items/i); + } + + /** + * Get the count of plugin rows + */ + public async getPluginCount(): Promise { + return this.rows.count(); + } + + /** + * Get all plugin names from the current page + */ + public async getPluginNames(): Promise> { + const count = await this.rows.count(); + + if (count === 0) { + return []; + } + + return this.rows.locator("td:first-child").allTextContents(); + } + + /** + * Get all plugin sources from the current page + */ + public async getPluginSources(): Promise> { + const count = await this.rows.count(); + + if (count === 0) { + return []; + } + + return this.rows.locator("td:nth-child(2)").allTextContents(); + } + + /** + * Navigate to Plugins page + */ + public async navigate(): Promise { + await this.navigateTo("/plugins"); + } + + /** + * Navigate to Plugins page with specific pagination parameters + */ + public async navigateWithParams(limit: number, offset: number): Promise { + await this.navigateTo(`/plugins?limit=${limit}&offset=${offset}`); + await this.waitForLoad(); + } + + /** + * Wait for the plugins page to fully load + */ + public async waitForLoad(): Promise { + await this.table.waitFor({ state: "visible", timeout: 30_000 }); + await this.waitForTableData(); + } + + /** + * Wait for table data to be loaded (not skeleton loaders) + */ + public async waitForTableData(): Promise { + // Wait for actual data cells to appear (not skeleton loaders) + await this.page.waitForFunction( + () => { + const table = document.querySelector('[data-testid="table-list"]'); + + if (!table) { + return false; + } + + // Check for actual content in tbody (real data, not skeleton) + const cells = table.querySelectorAll("tbody tr td"); + + return cells.length > 0; + }, + undefined, + { timeout: 30_000 }, + ); + } +} diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/plugins.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/plugins.spec.ts new file mode 100644 index 0000000000000..7e978fa4f38e2 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/plugins.spec.ts @@ -0,0 +1,106 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { expect, test } from "@playwright/test"; + +import { PluginsPage } from "../pages/PluginsPage"; + +test.describe("Plugins Page", () => { + let pluginsPage: PluginsPage; + + test.beforeEach(async ({ page }) => { + pluginsPage = new PluginsPage(page); + await pluginsPage.navigate(); + await pluginsPage.waitForLoad(); + }); + + test("verify plugins page heading is visible", async () => { + await expect(pluginsPage.heading).toBeVisible(); + }); + + test("verify plugins table is visible", async () => { + await expect(pluginsPage.table).toBeVisible(); + }); + + test("verify plugins list displays with data", async () => { + const count = await pluginsPage.getPluginCount(); + + expect(count).toBeGreaterThan(0); + }); + + test("verify each plugin has a name", async () => { + const pluginNames = await pluginsPage.getPluginNames(); + + expect(pluginNames.length).toBeGreaterThan(0); + + for (const name of pluginNames) { + expect(name.trim().length).toBeGreaterThan(0); + } + }); + + test("verify each plugin has a source", async () => { + const pluginSources = await pluginsPage.getPluginSources(); + + expect(pluginSources.length).toBeGreaterThan(0); + + for (const source of pluginSources) { + expect(source.trim().length).toBeGreaterThan(0); + } + }); + + test("verify plugin names and sources have matching counts", async () => { + const pluginNames = await pluginsPage.getPluginNames(); + const pluginSources = await pluginsPage.getPluginSources(); + + expect(pluginNames.length).toBe(pluginSources.length); + }); +}); + +test.describe("Plugins Pagination", () => { + test("should navigate through pages when pagination is available", async ({ page }) => { + const pluginsPage = new PluginsPage(page); + + // Navigate with small page size to trigger pagination if enough data exists + await pluginsPage.navigateWithParams(5, 0); + await pluginsPage.waitForLoad(); + + const nextButton = pluginsPage.paginationNextButton; + const prevButton = pluginsPage.paginationPrevButton; + + // Pagination controls must be visible - fail explicitly if not available + await expect(nextButton).toBeVisible({ + timeout: 5000, + }); + await expect(prevButton).toBeVisible(); + + // Test pagination flow + const firstPagePlugins = await pluginsPage.getPluginNames(); + + await nextButton.click(); + await pluginsPage.waitForTableData(); + + const secondPagePlugins = await pluginsPage.getPluginNames(); + expect(secondPagePlugins).not.toEqual(firstPagePlugins); + + await prevButton.click(); + await pluginsPage.waitForTableData(); + + const backToFirstPage = await pluginsPage.getPluginNames(); + expect(backToFirstPage).toEqual(firstPagePlugins); + }); +}); diff --git a/task-sdk/tests/task_sdk/serde/test_serializers.py b/task-sdk/tests/task_sdk/serde/test_serializers.py index a090e8d6b13e0..121ff8bf4436b 100644 --- a/task-sdk/tests/task_sdk/serde/test_serializers.py +++ b/task-sdk/tests/task_sdk/serde/test_serializers.py @@ -40,7 +40,7 @@ from airflow.sdk._shared.module_loading import qualname from airflow.sdk.definitions.param import Param, ParamsDict -from airflow.sdk.serde import CLASSNAME, DATA, VERSION, decode, deserialize, serialize +from airflow.sdk.serde import CLASSNAME, DATA, VERSION, decode, deserialize, serialize, MAX_RECURSION_DEPTH from airflow.sdk.serde.serializers import builtin from tests_common.test_utils.config import conf_vars @@ -679,3 +679,7 @@ def test_uuid_roundtrip(self, uuid_value): deserialized = deserialize(serialized) assert isinstance(deserialized, uuid.UUID) assert uuid_value == deserialized + def test_serde_serialize_recursion_limit(self): + depth = MAX_RECURSION_DEPTH + with pytest.raises(RecursionError, match="maximum recursion depth reached for serialization"): + serialize(object(), depth=depth)