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 5c9cf6a5c12 Added E2E tests for Variables (#61571)
5c9cf6a5c12 is described below
commit 5c9cf6a5c1292b1ecfb636da6dfb2f8d65de0af2
Author: Sarthak Vaish <[email protected]>
AuthorDate: Mon Feb 16 18:31:58 2026 +0530
Added E2E tests for Variables (#61571)
---
.../src/airflow/ui/tests/e2e/pages/VariablePage.ts | 101 ++++++++
.../airflow/ui/tests/e2e/specs/variable.spec.ts | 262 +++++++++++++++++++++
2 files changed, 363 insertions(+)
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/VariablePage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/VariablePage.ts
new file mode 100644
index 00000000000..da34315fa4a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/VariablePage.ts
@@ -0,0 +1,101 @@
+/*!
+ * 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 VariablePage extends BasePage {
+ public readonly addButton: Locator;
+ public readonly importButton: Locator;
+ public readonly searchInput: Locator;
+ public readonly selectAllCheckbox: Locator;
+ public readonly table: Locator;
+ public readonly tableRows: Locator;
+
+ public constructor(page: Page) {
+ super(page);
+
+ this.searchInput = page.getByTestId("search-dags");
+ this.addButton = page.getByRole("button", { name: /add/i });
+ this.importButton = page.getByRole("button", { name: "Import Variables" });
+ this.table = page.getByTestId("table-list");
+ this.tableRows = this.table.locator("tbody tr");
+ this.selectAllCheckbox = page.locator("thead input[type='checkbox']");
+ }
+
+ public async getVariableKeys(): Promise<Array<string>> {
+ await this.waitForLoad();
+ const count = await this.tableRows.count();
+
+ if (count === 0) {
+ return [];
+ }
+ const keys = await
this.tableRows.locator("td:nth-child(2)").allTextContents();
+
+ return keys.map((key) => key.trim()).filter(Boolean);
+ }
+
+ public async navigate(): Promise<void> {
+ await this.navigateTo("/variables");
+ }
+
+ public rowByKey(key: string): Locator {
+ return this.page.locator(`tr:has-text("${key}")`);
+ }
+
+ public async search(key: string) {
+ await this.searchInput.fill(key);
+ }
+
+ public async selectRow(key: string) {
+ const row = this.rowByKey(key);
+ const checkbox = row.locator('[id^="checkbox"][id$=":control"]');
+
+ await checkbox.click();
+ }
+
+ public async waitForLoad(): Promise<void> {
+ await this.table.waitFor({ state: "visible", timeout: 15_000 });
+ await this.waitForTableData();
+ }
+
+ private async waitForTableData(): Promise<void> {
+ await this.page.waitForFunction(
+ () => {
+ const table = document.querySelector('[data-testid="table-list"]');
+
+ if (!table) return false;
+
+ if (document.body.textContent.includes("No variables found")) {
+ return true;
+ }
+
+ const rows = table.querySelectorAll("tbody tr");
+
+ if (rows.length === 0) return false;
+
+ const keyCells = table.querySelectorAll("tbody tr td:nth-child(2)");
+
+ return [...keyCells].some((cell) => Boolean(cell.textContent.trim()));
+ },
+ undefined,
+ { timeout: 60_000 },
+ );
+ }
+}
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/variable.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/variable.spec.ts
new file mode 100644
index 00000000000..f0ae73dc101
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/variable.spec.ts
@@ -0,0 +1,262 @@
+/*!
+ * 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 { test, expect } from "@playwright/test";
+import { AUTH_FILE } from "playwright.config";
+
+import { VariablePage } from "../pages/VariablePage";
+
+test.describe("Variables Page", () => {
+ let variablesPage: VariablePage;
+ let createVariables = 6;
+
+ const createdVariables: Array<{
+ description: string;
+ key: string;
+ value: string;
+ }> = [];
+
+ test.beforeAll(async ({ browser }) => {
+ test.setTimeout(420_000); // for slower browsers
+ const context = await browser.newContext({ storageState: AUTH_FILE });
+ const page = await context.newPage();
+
+ variablesPage = new VariablePage(page);
+
+ await variablesPage.navigate();
+
+ for (let i = 0; i < createVariables; i++) {
+ const variable = {
+ description: `description_${i}`,
+ key: `e2e_var_${Date.now()}_${i}_${Math.random().toString(36).slice(2,
8)}`,
+ value: `value_${i}`,
+ };
+
+ createdVariables.push(variable);
+
+ await variablesPage.addButton.click();
+
+ await expect(page.getByRole("heading", { name: /add/i })).toBeVisible({
timeout: 20_000 });
+
+ await page.getByLabel(/key/i).fill(variable.key);
+ await page.getByLabel(/value/i).fill(variable.value);
+
+ if (variable.description) {
+ await page.getByLabel(/description/i).fill(variable.description);
+ }
+
+ await page.getByRole("button", { name: /save/i }).click();
+
+ await expect(variablesPage.rowByKey(variable.key)).toHaveCount(1);
+ }
+
+ await context.close();
+ });
+
+ test.beforeEach(async ({ page }) => {
+ variablesPage = new VariablePage(page);
+ await variablesPage.navigate();
+ await variablesPage.waitForLoad();
+ });
+
+ test("verify variables table displays", async () => {
+ await expect(variablesPage.table).toBeVisible();
+
+ const rowCount = await variablesPage.tableRows.count();
+
+ expect(rowCount).toBeGreaterThan(0);
+ });
+
+ test("verify search filters", async () => {
+ const target = createdVariables.at(0);
+
+ expect(target).toBeDefined();
+
+ if (!target) {
+ throw new Error("No variables available for test");
+ }
+
+ const targetKey = target.key;
+
+ await variablesPage.search(targetKey);
+ await expect(variablesPage.tableRows).toHaveCount(1);
+ await expect(variablesPage.rowByKey(targetKey)).toBeVisible();
+ });
+
+ test("verify editing a variable", async ({ page }) => {
+ const target = createdVariables.at(1);
+
+ expect(target).toBeDefined();
+
+ if (!target) {
+ throw new Error("No variable available for edit test");
+ }
+
+ await variablesPage.rowByKey(target.key).getByRole("button", { name:
/edit/i }).click();
+
+ await expect(page.getByRole("heading", { name: /edit/i })).toBeVisible();
+
+ await page.getByLabel(/description/i).fill("updated via e2e");
+ await page.getByRole("button", { name: /save/i }).click();
+
+ await variablesPage.waitForLoad();
+
+ await expect(variablesPage.rowByKey(target.key)).toContainText("updated
via e2e");
+ });
+
+ test("verify deleting the variable", async ({ page }) => {
+ const targetKey = `delete_test_${Date.now()}`;
+
+ const response = await page.request.post("/api/v2/variables", {
+ data: {
+ description: "to_be_deleted",
+ key: targetKey,
+ value: "Variable_Deletion",
+ },
+ });
+
+ expect(response.ok()).toBeTruthy();
+ expect(response.status()).toBe(201);
+
+ await variablesPage.navigate();
+ await variablesPage.waitForLoad();
+
+ await variablesPage.search(targetKey);
+ await expect(variablesPage.tableRows).toHaveCount(1, { timeout: 10_000 });
+ await expect(variablesPage.rowByKey(targetKey)).toHaveCount(1, { timeout:
10_000 });
+
+ await variablesPage.selectRow(targetKey);
+ await page.getByRole("button", { name: /^delete$/i }).click();
+
+ const dialog = page.getByRole("dialog");
+
+ await expect(dialog.getByRole("heading", { name: /delete\s+1\s+variable/i
})).toBeVisible();
+
+ const codeBlock = dialog.locator("code");
+
+ await expect(codeBlock).toContainText(targetKey);
+
+ await dialog.getByRole("button", { name: /yes,\s*delete/i }).click();
+
+ await expect(variablesPage.rowByKey(targetKey)).toHaveCount(0);
+ });
+
+ test("verify importing variables using Import Variables button", async ({
page }) => {
+ const uniqueId = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
+
+ const importPayload = {
+ [`import_var_${uniqueId}_1`]: {
+ description: "imported via e2e 1",
+ value: "imported_value_1",
+ },
+ [`import_var_${uniqueId}_2`]: {
+ description: "imported via e2e 2",
+ value: "imported_value_2",
+ },
+ };
+
+ await variablesPage.page.getByRole("button", { name: /import variables/i
}).click();
+
+ const dialog = page.getByRole("dialog");
+
+ await expect(dialog.getByRole("heading", { name: /import variables/i
})).toBeVisible();
+
+ const fileInput = dialog.locator('input[type="file"]');
+
+ await fileInput.setInputFiles({
+ buffer: Buffer.from(JSON.stringify(importPayload, undefined, 2)),
+ mimeType: "application/json",
+ name: "variables.json",
+ });
+
+ await dialog.getByRole("button", { name: /import/i }).click();
+
+ await variablesPage.waitForLoad();
+
+ for (const key of Object.keys(importPayload)) {
+ await expect(variablesPage.rowByKey(key)).toHaveCount(1);
+
+ const variable = importPayload[key];
+
+ if (!variable) {
+ throw new Error(`Missing import payload for key: ${key}`);
+ }
+
+ createdVariables.push({
+ description: variable.description,
+ key,
+ value: variable.value,
+ });
+ }
+ });
+
+ test.afterAll(async ({ browser }) => {
+ if (createdVariables.length === 0) {
+ return;
+ }
+
+ const context = await browser.newContext({ storageState: AUTH_FILE });
+ const page = await context.newPage();
+
+ variablesPage = new VariablePage(page);
+
+ await variablesPage.navigate();
+ await variablesPage.waitForLoad();
+
+ const keysToDelete: Array<string> = [];
+
+ for (const variable of createdVariables) {
+ const row = variablesPage.rowByKey(variable.key);
+
+ if ((await row.count()) > 0) {
+ await variablesPage.selectRow(variable.key);
+ keysToDelete.push(variable.key);
+ }
+ }
+
+ if (keysToDelete.length === 0) {
+ await context.close();
+
+ return;
+ }
+
+ await page.getByRole("button", { name: /^delete$/i }).click();
+
+ const dialog = page.getByRole("dialog");
+
+ await expect(
+ dialog.getByRole("heading", {
+ name: new RegExp(`delete\\s+${keysToDelete.length}\\s+variable`, "i"),
+ }),
+ ).toBeVisible();
+
+ const codeBlock = dialog.locator("code");
+
+ for (const key of keysToDelete) {
+ await expect(codeBlock).toContainText(key);
+ }
+
+ await dialog.getByRole("button", { name: /yes,\s*delete/i }).click();
+
+ for (const key of keysToDelete) {
+ await expect(variablesPage.rowByKey(key)).toHaveCount(0);
+ }
+
+ await context.close();
+ });
+});