From 1cf538302da980c93eca5a6f08ba6752d9cddd4d Mon Sep 17 00:00:00 2001 From: Aman Kumar Date: Sat, 14 Feb 2026 18:48:45 +0530 Subject: [PATCH] ADMIN-006: Verify Configuration Page functionality --- .../src/airflow/ui/playwright.config.ts | 11 +- .../src/airflow/ui/tests/e2e/README.md | 6 + .../airflow/ui/tests/e2e/pages/ConfigsPage.ts | 95 +++++++++++++++ .../ui/tests/e2e/specs/configs.spec.ts | 115 ++++++++++++++++++ 4 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 airflow-core/src/airflow/ui/tests/e2e/pages/ConfigsPage.ts create mode 100644 airflow-core/src/airflow/ui/tests/e2e/specs/configs.spec.ts diff --git a/airflow-core/src/airflow/ui/playwright.config.ts b/airflow-core/src/airflow/ui/playwright.config.ts index 3e00ad21e59b4..181e987a9b2ac 100644 --- a/airflow-core/src/airflow/ui/playwright.config.ts +++ b/airflow-core/src/airflow/ui/playwright.config.ts @@ -24,8 +24,15 @@ export const testConfig = { asset: { name: process.env.TEST_ASSET_NAME ?? "s3://dag1/output_1.txt", }, - connection: { - baseUrl: process.env.AIRFLOW_UI_BASE_URL ?? "http://localhost:28080", + configPage: { + expectedHeading: process.env.TEST_CONFIG_PAGE_HEADING ?? "Airflow Configuration", + expectedKey: process.env.TEST_CONFIG_PAGE_KEY ?? "dags_folder", + expectedSection: process.env.TEST_CONFIG_PAGE_SECTION ?? "core", + expectsTableData: (process.env.TEST_CONFIG_PAGE_EXPECTS_TABLE_DATA ?? "false").toLowerCase() === "true", + forbiddenMessage: + process.env.TEST_CONFIG_PAGE_FORBIDDEN_MESSAGE ?? + "Your Airflow administrator chose not to expose the configuration", + path: process.env.TEST_CONFIG_PAGE_PATH ?? "/configs", }, credentials: { password: process.env.TEST_PASSWORD ?? "admin", diff --git a/airflow-core/src/airflow/ui/tests/e2e/README.md b/airflow-core/src/airflow/ui/tests/e2e/README.md index da7597ec716c7..251557fafd340 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/README.md +++ b/airflow-core/src/airflow/ui/tests/e2e/README.md @@ -112,6 +112,12 @@ Environment variables (with defaults): - `TEST_USERNAME` - Username (default: `airflow`) - `TEST_PASSWORD` - Password (default: `airflow`) - `TEST_DAG_ID` - Test DAG ID (default: `example_bash_operator`) +- `TEST_CONFIG_PAGE_PATH` - Config page path (default: `/configs`) +- `TEST_CONFIG_PAGE_HEADING` - Config page heading (default: `Airflow Configuration`) +- `TEST_CONFIG_PAGE_EXPECTS_TABLE_DATA` - `true` to assert table rows are exposed; `false` to assert 403 message (default: `false`) +- `TEST_CONFIG_PAGE_SECTION` - Section asserted when table data is exposed (default: `core`) +- `TEST_CONFIG_PAGE_KEY` - Key asserted when table data is exposed (default: `dags_folder`) +- `TEST_CONFIG_PAGE_FORBIDDEN_MESSAGE` - Message asserted when table data is hidden (default: `Your Airflow administrator chose not to expose the configuration`) ## Debugging diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/ConfigsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/ConfigsPage.ts new file mode 100644 index 0000000000000..46a4454fb2664 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/ConfigsPage.ts @@ -0,0 +1,95 @@ +/*! + * 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"; + +export class ConfigsPage extends BasePage { + public readonly forbiddenStatus: Locator; + public readonly heading: Locator; + public readonly rows: Locator; + public readonly table: Locator; + + public constructor(page: Page) { + super(page); + this.heading = page.getByRole("heading", { name: /configuration/i }); + this.table = page.getByTestId("table-list"); + this.forbiddenStatus = page.getByText(/403 forbidden/i); + this.rows = this.table.locator("tbody tr").filter({ + has: page.locator("td"), + }); + } + + public async getColumnNames(): Promise> { + return this.table.locator("thead th").allTextContents(); + } + + public async getRowCount(): Promise { + return this.rows.count(); + } + + public async getRowDetails(index: number): Promise<{ key: string; section: string; value: string }> { + const row = this.rows.nth(index); + const cells = row.locator("td"); + + const section = await cells.nth(0).textContent(); + const key = await cells.nth(1).textContent(); + const value = await cells.nth(2).textContent(); + + return { + key: (key ?? "").trim(), + section: (section ?? "").trim(), + value: (value ?? "").trim(), + }; + } + + public async hasSectionAndKey(section: string, key: string): Promise { + const sectionLower = section.toLowerCase(); + const keyLower = key.toLowerCase(); + const rowCount = await this.getRowCount(); + + for (let i = 0; i < rowCount; i++) { + const row = await this.getRowDetails(i); + + if (row.section.toLowerCase() === sectionLower && row.key.toLowerCase() === keyLower) { + return true; + } + } + + return false; + } + + public async navigate(path = "/configs"): Promise { + await this.navigateTo(path); + } + + public async waitForLoad(): Promise { + await this.heading.waitFor({ state: "visible", timeout: 30_000 }); + await this.page.waitForFunction( + () => { + const table = document.querySelector('[data-testid="table-list"]'); + const bodyText = document.body.textContent; + + return table !== null || bodyText.includes("403 Forbidden"); + }, + undefined, + { timeout: 30_000 }, + ); + } +} diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/configs.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/configs.spec.ts new file mode 100644 index 0000000000000..21760e90425e6 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/configs.spec.ts @@ -0,0 +1,115 @@ +/*! + * 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 { testConfig } from "playwright.config"; + +import { ConfigsPage } from "../pages/ConfigsPage"; + +const escapeForRegex = (value: string): string => value.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&"); + +test.describe("Configuration Page", () => { + let configsPage: ConfigsPage; + const { configPage } = testConfig; + + test.beforeEach(async ({ page }) => { + configsPage = new ConfigsPage(page); + await configsPage.navigate(configPage.path); + await configsPage.waitForLoad(); + }); + + test("verify configuration displays", async () => { + await expect(configsPage.heading).toHaveText(new RegExp(configPage.expectedHeading, "i")); + + if (!configPage.expectsTableData) { + await expect(configsPage.forbiddenStatus).toBeVisible(); + await expect( + configsPage.page.getByText(new RegExp(escapeForRegex(configPage.forbiddenMessage), "i")), + ).toBeVisible(); + + return; + } + + await expect(configsPage.table).toBeVisible(); + + const rowCount = await configsPage.getRowCount(); + + expect(rowCount).toBeGreaterThan(0); + + const columns = await configsPage.getColumnNames(); + + expect(columns).toEqual(expect.arrayContaining(["Section", "Key", "Value"])); + }); + + test("verify configuration page is accessible via Admin menu", async ({ page }) => { + await page.goto("/"); + + await page.getByRole("button", { name: /^admin$/i }).click(); + + const configMenuItem = page.getByRole("menuitem", { name: /^config$/i }); + + await expect(configMenuItem).toBeVisible(); + await configMenuItem.click(); + + await configsPage.waitForLoad(); + expect(page.url()).toContain(configPage.path); + + if (!configPage.expectsTableData) { + await expect(configsPage.forbiddenStatus).toBeVisible(); + await expect( + configsPage.page.getByText(new RegExp(escapeForRegex(configPage.forbiddenMessage), "i")), + ).toBeVisible(); + + return; + } + + await expect(configsPage.table).toBeVisible(); + }); + + test("verify configuration section and key are rendered", async () => { + test.skip( + !configPage.expectsTableData, + "Set TEST_CONFIG_PAGE_EXPECTS_TABLE_DATA=true when configuration values are exposed.", + ); + + const sectionAndKeyExists = await configsPage.hasSectionAndKey( + configPage.expectedSection, + configPage.expectedKey, + ); + + expect(sectionAndKeyExists).toBe(true); + }); + + test("verify section, key and value are populated in configuration rows", async () => { + test.skip( + !configPage.expectsTableData, + "Set TEST_CONFIG_PAGE_EXPECTS_TABLE_DATA=true when configuration values are exposed.", + ); + + const rowCount = await configsPage.getRowCount(); + const rowsToCheck = Math.min(rowCount, 3); + + for (let i = 0; i < rowsToCheck; i++) { + const { key, section, value } = await configsPage.getRowDetails(i); + + expect(section).not.toEqual(""); + expect(key).not.toEqual(""); + expect(value).not.toEqual(""); + } + }); +});