diff --git a/airflow/ui/.gitignore b/airflow/ui/.gitignore new file mode 100644 index 0000000000000..2d2899959be4b --- /dev/null +++ b/airflow/ui/.gitignore @@ -0,0 +1,5 @@ +# Playwright +test-results/ +playwright-report/ +.playwright/ +playwright/.cache/ diff --git a/airflow/ui/package.json b/airflow/ui/package.json index 80a856114a0f2..6c7d1f323f461 100644 --- a/airflow/ui/package.json +++ b/airflow/ui/package.json @@ -13,7 +13,13 @@ "preview": "vite preview", "codegen": "openapi-rq -i \"../api_fastapi/core_api/openapi/v1-generated.yaml\" -c axios --format prettier -o openapi-gen --operationId", "test": "vitest run", - "coverage": "vitest run --coverage" + "coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:chromium": "playwright test --project=chromium", + "test:e2e:firefox": "playwright test --project=firefox", + "test:e2e:webkit": "playwright test --project=webkit" }, "dependencies": { "@chakra-ui/anatomy": "^2.2.2", @@ -53,6 +59,7 @@ }, "devDependencies": { "@7nohe/openapi-react-query-codegen": "^1.6.0", + "@playwright/test": "^1.48.2", "@eslint/compat": "^1.1.1", "@eslint/js": "^9.10.0", "@stylistic/eslint-plugin": "^2.8.0", @@ -86,4 +93,4 @@ "vitest": "^2.1.9", "web-worker": "^1.3.0" } -} +} \ No newline at end of file diff --git a/airflow/ui/playwright.config.ts b/airflow/ui/playwright.config.ts new file mode 100644 index 0000000000000..7bdacc58c7e11 --- /dev/null +++ b/airflow/ui/playwright.config.ts @@ -0,0 +1,40 @@ +/*! + * 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 { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e/specs", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: process.env.AIRFLOW_BASE_URL || "http://localhost:8080", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + ], +}); diff --git a/airflow/ui/tests/e2e/README.md b/airflow/ui/tests/e2e/README.md new file mode 100644 index 0000000000000..ff78c7b7c1449 --- /dev/null +++ b/airflow/ui/tests/e2e/README.md @@ -0,0 +1,143 @@ +# Airflow UI End-to-End Tests + +This directory contains E2E tests for the Apache Airflow UI using Playwright. + +## Prerequisites + +- Node.js (v16 or later) +- pnpm package manager +- A running Airflow instance (for tests to interact with) + +## Setup + +1. Install dependencies: +```bash +cd airflow/ui +pnpm install +``` + +2. Install Playwright browsers: +```bash +pnpm exec playwright install +``` + +## Running Tests + +### Run all E2E tests: +```bash +pnpm test:e2e +``` + +### Run tests in UI mode (interactive): +```bash +pnpm test:e2e:ui +``` + +### Run tests in debug mode: +```bash +pnpm test:e2e:debug +``` + +### Run tests on a specific browser: +```bash +pnpm test:e2e:chromium # Chrome +pnpm test:e2e:firefox # Firefox +pnpm test:e2e:webkit # Safari +``` + +### Run a specific test file: +```bash +pnpm exec playwright test tests/e2e/specs/variables.spec.ts +``` + +## Configuration + +Test configuration is managed in `testConfig.ts`. You can override values using environment variables: + +```bash +# Set custom Airflow URL +export AIRFLOW_BASE_URL=http://localhost:8080 + +# Set custom credentials +export AIRFLOW_USERNAME=admin +export AIRFLOW_PASSWORD=admin + +# Run tests +pnpm test:e2e +``` + +## Test Structure + +``` +tests/e2e/ +├── pages/ # Page Object Models +│ ├── BasePage.ts # Base class for all pages +│ ├── LoginPage.ts # Login page interactions +│ └── VariablesPage.ts # Variables page interactions +├── specs/ # Test specifications +│ └── variables.spec.ts # Variables page tests +└── testConfig.ts # Test configuration +``` + +## Page Object Model (POM) + +All tests follow the Page Object Model pattern: +- **Page Objects** encapsulate UI interactions +- **Test Specs** use page objects to test functionality +- This keeps tests maintainable and readable + +## Writing New Tests + +1. Create a new Page Object in `pages/` (extend `BasePage`) +2. Create a new test spec in `specs/` +3. Use the page object methods in your tests + +Example: +```typescript +import { test } from "@playwright/test"; +import { LoginPage } from "../pages/LoginPage"; +import { MyNewPage } from "../pages/MyNewPage"; + +test.describe("My Feature", () => { + test.beforeEach(async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.login(); + }); + + test("should do something", async ({ page }) => { + const myPage = new MyNewPage(page); + await myPage.doSomething(); + // Add assertions + }); +}); +``` + +## Reports + +After running tests, view the HTML report: +```bash +pnpm exec playwright show-report +``` + +## Troubleshooting + +### Tests failing to connect to Airflow + +Make sure Airflow is running and accessible at the URL specified in `testConfig.ts` or `AIRFLOW_BASE_URL` environment variable. + +### Authentication issues + +Verify that the credentials in `testConfig.ts` or environment variables (`AIRFLOW_USERNAME`, `AIRFLOW_PASSWORD`) are correct. + +### Timeout errors + +Increase timeouts in `testConfig.ts` if your Airflow instance is slow to respond. + +## Contributing + +When adding new E2E tests: +- Follow the existing POM pattern +- Ensure tests clean up their own data in `afterAll` hooks +- Use unique test data prefixes to avoid conflicts +- Make tests work across all browsers (Chromium, Firefox, WebKit) +- Avoid hardcoded values - use `testConfig` or dynamic generation diff --git a/airflow/ui/tests/e2e/pages/BasePage.ts b/airflow/ui/tests/e2e/pages/BasePage.ts new file mode 100644 index 0000000000000..d26650a3c26c2 --- /dev/null +++ b/airflow/ui/tests/e2e/pages/BasePage.ts @@ -0,0 +1,73 @@ +/*! + * 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, Locator } from "@playwright/test"; + +export class BasePage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async navigateTo(path: string): Promise { + await this.page.goto(path); + } + + async waitForElement(selector: string, timeout = 30000): Promise { + return this.page.waitForSelector(selector, { timeout }); + } + + async waitForNavigation(action: () => Promise): Promise { + await Promise.all([this.page.waitForLoadState("networkidle"), action()]); + } + + async click(selector: string): Promise { + await this.page.click(selector); + } + + async fill(selector: string, value: string): Promise { + await this.page.fill(selector, value); + } + + async getText(selector: string): Promise { + return this.page.textContent(selector) || ""; + } + + async isVisible(selector: string): Promise { + try { + return await this.page.isVisible(selector); + } catch { + return false; + } + } + + async exists(selector: string): Promise { + const element = await this.page.$(selector); + return element !== null; + } + + async takeScreenshot(name: string): Promise { + await this.page.screenshot({ path: `screenshots/${name}.png` }); + } + + async wait(ms: number): Promise { + await this.page.waitForTimeout(ms); + } +} diff --git a/airflow/ui/tests/e2e/pages/LoginPage.ts b/airflow/ui/tests/e2e/pages/LoginPage.ts new file mode 100644 index 0000000000000..07999c0bffc7a --- /dev/null +++ b/airflow/ui/tests/e2e/pages/LoginPage.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 type { Page } from "@playwright/test"; + +import { BasePage } from "./BasePage"; +import { testConfig } from "../testConfig"; + +export class LoginPage extends BasePage { + constructor(page: Page) { + super(page); + } + + async goto(): Promise { + await this.navigateTo(testConfig.paths.login); + } + + async login( + username: string = testConfig.username, + password: string = testConfig.password, + ): Promise { + // Navigate to login page + await this.goto(); + + // Check if already logged in + const isLoggedIn = await this.isVisible('a[href="/logout"]'); + if (isLoggedIn) { + return; + } + + // Fill login form + await this.fill('input[name="username"]', username); + await this.fill('input[name="password"]', password); + + // Submit and wait for navigation + await this.waitForNavigation(async () => { + await this.click('button[type="submit"]'); + }); + } + + async isOnLoginPage(): Promise { + return this.exists('input[name="username"]'); + } +} diff --git a/airflow/ui/tests/e2e/pages/VariablesPage.ts b/airflow/ui/tests/e2e/pages/VariablesPage.ts new file mode 100644 index 0000000000000..a81e2ea3e2341 --- /dev/null +++ b/airflow/ui/tests/e2e/pages/VariablesPage.ts @@ -0,0 +1,376 @@ +/*! + * 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"; +import { expect } from "@playwright/test"; + +import { BasePage } from "./BasePage"; +import { testConfig } from "../testConfig"; + +interface VariableData { + key: string; + value: string; + description?: string; + isEncrypted?: boolean; +} + +export class VariablesPage extends BasePage { + // Locators + private searchInput = 'input[placeholder="Search Keys"]'; + private addButton = 'button:has-text("Add")'; + private importButton = 'button:has-text("Import")'; + private dataTable = '[role="table"]'; + private tableRow = '[role="row"]'; + private tableHeader = '[role="columnheader"]'; + private actionBar = '[data-scope="action-bar"]'; + + // Dialog locators + private dialog = '[role="dialog"]'; + private dialogKeyInput = 'input[name="key"]'; + private dialogValueInput = 'textarea[name="value"]'; + private dialogDescriptionInput = 'textarea[name="description"]'; + private dialogEncryptedCheckbox = 'input[type="checkbox"][name="is_encrypted"]'; + private dialogSubmitButton = 'button[type="submit"]'; + private dialogCancelButton = 'button:has-text("Cancel")'; + + // Confirmation dialog + private confirmDeleteButton = 'button:has-text("Yes, Delete")'; + + // File input for import + private fileInput = 'input[type="file"]'; + + constructor(page: Page) { + super(page); + } + + async goto(): Promise { + await this.navigateTo(testConfig.paths.variables); + await this.wait(1000); // Wait for page to stabilize + } + + // Search functionality + async searchVariables(pattern: string): Promise { + await this.fill(this.searchInput, pattern); + await this.wait(500); // Wait for search to apply + } + + async clearSearch(): Promise { + await this.fill(this.searchInput, ""); + await this.wait(500); + } + + // Create variable + async clickAddVariable(): Promise { + await this.click(this.addButton); + await this.waitForElement(this.dialog); + } + + async createVariable( + key: string, + value: string, + description: string = "", + isEncrypted: boolean = false, + ): Promise { + await this.clickAddVariable(); + + // Fill form + await this.fill(this.dialogKeyInput, key); + await this.fill(this.dialogValueInput, value); + if (description) { + await this.fill(this.dialogDescriptionInput, description); + } + if (isEncrypted) { + await this.click(this.dialogEncryptedCheckbox); + } + + // Submit + await this.click(this.dialogSubmitButton); + await this.wait(1000); // Wait for variable to be created + } + + // Edit variable + async clickEditVariable(key: string): Promise { + const row = await this.getVariableRow(key); + if (!row) { + throw new Error(`Variable with key "${key}" not found`); + } + + // Find edit button in the row + const editButton = await row.$('button[aria-label*="edit"], button:has-text("Edit")'); + if (!editButton) { + throw new Error(`Edit button not found for variable "${key}"`); + } + + await editButton.click(); + await this.waitForElement(this.dialog); + } + + async editVariable( + key: string, + newValue?: string, + newDescription?: string, + newIsEncrypted?: boolean, + ): Promise { + await this.clickEditVariable(key); + + // Update fields + if (newValue !== undefined) { + await this.fill(this.dialogValueInput, newValue); + } + if (newDescription !== undefined) { + await this.fill(this.dialogDescriptionInput, newDescription); + } + if (newIsEncrypted !== undefined) { + const checkbox = await this.page.$(this.dialogEncryptedCheckbox); + const isChecked = await checkbox?.isChecked(); + if (isChecked !== newIsEncrypted) { + await this.click(this.dialogEncryptedCheckbox); + } + } + + // Submit + await this.click(this.dialogSubmitButton); + await this.wait(1000); + } + + // Delete variable + async clickDeleteVariable(key: string): Promise { + const row = await this.getVariableRow(key); + if (!row) { + throw new Error(`Variable with key "${key}" not found`); + } + + // Find delete button in the row + const deleteButton = await row.$('button[aria-label*="delete"], button:has-text("Delete")'); + if (!deleteButton) { + throw new Error(`Delete button not found for variable "${key}"`); + } + + await deleteButton.click(); + await this.waitForElement(this.confirmDeleteButton); + } + + async deleteVariable(key: string): Promise { + await this.clickDeleteVariable(key); + await this.click(this.confirmDeleteButton); + await this.wait(1000); + } + + // Select variables for bulk operations + async selectVariable(key: string): Promise { + const row = await this.getVariableRow(key); + if (!row) { + throw new Error(`Variable with key "${key}" not found`); + } + + const checkbox = await row.$('input[type="checkbox"]'); + if (!checkbox) { + throw new Error(`Checkbox not found for variable "${key}"`); + } + + await checkbox.click(); + } + + async selectMultipleVariables(keys: string[]): Promise { + for (const key of keys) { + await this.selectVariable(key); + } + } + + async deleteMultipleVariables(keys: string[]): Promise { + // Select all variables + await this.selectMultipleVariables(keys); + + // Wait for action bar to appear + await this.waitForElement(this.actionBar); + + // Click delete button in action bar + const deleteButton = await this.page.$(`${this.actionBar} button:has-text("Delete")`); + if (!deleteButton) { + throw new Error("Delete button not found in action bar"); + } + + await deleteButton.click(); + await this.waitForElement(this.confirmDeleteButton); + await this.click(this.confirmDeleteButton); + await this.wait(1000); + } + + // Import variables + async clickImportVariables(): Promise { + await this.click(this.importButton); + await this.wait(500); + } + + async importVariables(filePath: string): Promise { + await this.clickImportVariables(); + + // Upload file + const fileInputElement = await this.page.$(this.fileInput); + if (!fileInputElement) { + throw new Error("File input not found"); + } + + await fileInputElement.setInputFiles(filePath); + await this.wait(2000); // Wait for import to complete + } + + // Helper methods + async getVariableRow(key: string) { + const rows = await this.page.$$(this.tableRow); + + for (const row of rows) { + const text = await row.textContent(); + if (text?.includes(key)) { + return row; + } + } + + return null; + } + + async isVariableVisible(key: string): Promise { + const row = await this.getVariableRow(key); + return row !== null; + } + + async getVariableData(key: string): Promise { + const row = await this.getVariableRow(key); + if (!row) { + return null; + } + + const cells = await row.$$('[role="cell"]'); + if (cells.length < 4) { + return null; + } + + // Skip first cell (checkbox), then key, value, description, is_encrypted + const keyText = await cells[1].textContent(); + const valueText = await cells[2].textContent(); + const descriptionText = await cells[3].textContent(); + const isEncryptedText = await cells[4].textContent(); + + return { + key: keyText?.trim() || "", + value: valueText?.trim() || "", + description: descriptionText?.trim() || "", + isEncrypted: isEncryptedText?.toLowerCase().includes("true"), + }; + } + + // Sorting + async sortByColumn(columnName: string): Promise { + const header = await this.page.$(`${this.tableHeader}:has-text("${columnName}")`); + if (!header) { + throw new Error(`Column "${columnName}" not found`); + } + + await header.click(); + await this.wait(500); + } + + async getSortDirection(columnName: string): Promise<"asc" | "desc" | null> { + const header = await this.page.$(`${this.tableHeader}:has-text("${columnName}")`); + if (!header) { + return null; + } + + const ariaSort = await header.getAttribute("aria-sort"); + if (ariaSort === "ascending") return "asc"; + if (ariaSort === "descending") return "desc"; + return null; + } + + // Pagination + async goToPage(page: number): Promise { + const pageButton = await this.page.$(`button:has-text("${page}")`); + if (pageButton) { + await pageButton.click(); + await this.wait(500); + } + } + + async goToNextPage(): Promise { + const nextButton = await this.page.$('button[aria-label="Go to next page"]'); + if (nextButton) { + await nextButton.click(); + await this.wait(500); + } + } + + async goToPreviousPage(): Promise { + const prevButton = await this.page.$('button[aria-label="Go to previous page"]'); + if (prevButton) { + await prevButton.click(); + await this.wait(500); + } + } + + async getCurrentPage(): Promise { + // Try to find current page number in pagination + const paginationText = await this.page.textContent('[data-scope="pagination"]'); + if (paginationText) { + const match = paginationText.match(/Page (\d+)/); + if (match) { + return parseInt(match[1], 10); + } + } + return 1; + } + + async getTotalVariables(): Promise { + const paginationText = await this.page.textContent('[data-scope="pagination"]'); + if (paginationText) { + const match = paginationText.match(/of (\d+)/); + if (match) { + return parseInt(match[1], 10); + } + } + return 0; + } + + async getDisplayedVariablesCount(): Promise { + const rows = await this.page.$$(this.tableRow); + // Subtract 1 for header row + return Math.max(0, rows.length - 1); + } + + // Verification helpers + async verifyVariableExists( + key: string, + value?: string, + description?: string, + ): Promise { + const data = await this.getVariableData(key); + expect(data).not.toBeNull(); + + if (value !== undefined) { + expect(data?.value).toBe(value); + } + if (description !== undefined) { + expect(data?.description).toBe(description); + } + } + + async verifyVariableNotExists(key: string): Promise { + const isVisible = await this.isVariableVisible(key); + expect(isVisible).toBe(false); + } +} diff --git a/airflow/ui/tests/e2e/specs/variables.spec.ts b/airflow/ui/tests/e2e/specs/variables.spec.ts new file mode 100644 index 0000000000000..68655fa97ca60 --- /dev/null +++ b/airflow/ui/tests/e2e/specs/variables.spec.ts @@ -0,0 +1,377 @@ +/*! + * 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 * as fs from "fs"; +import * as path from "path"; + +import { testConfig } from "../testConfig"; +import { LoginPage } from "../pages/LoginPage"; +import { VariablesPage } from "../pages/VariablesPage"; + +test.describe("Variables Page - ADMIN-001", () => { + let loginPage: LoginPage; + let variablesPage: VariablesPage; + const testVariables: string[] = []; + + // Generate unique test data + const generateTestKey = (suffix: string) => { + const key = `${testConfig.testDataPrefix}${suffix}_${Date.now()}`; + testVariables.push(key); + return key; + }; + + test.beforeAll(async ({ browser }) => { + // Setup: Create test data + const context = await browser.newContext(); + const page = await context.newPage(); + + loginPage = new LoginPage(page); + variablesPage = new VariablesPage(page); + + // Login + await loginPage.login(); + await variablesPage.goto(); + + // Create baseline test variables for pagination (35+ variables) + console.log("Creating baseline test variables..."); + + for (let i = 1; i <= 35; i++) { + const key = generateTestKey(`baseline_${i}`); + await variablesPage.createVariable( + key, + `test_value_${i}`, + `Test description ${i}`, + false, + ); + } + + console.log(`Created ${testVariables.length} test variables`); + + await context.close(); + }); + + test.afterAll(async ({ browser }) => { + // Cleanup: Delete all test variables + const context = await browser.newContext(); + const page = await context.newPage(); + + loginPage = new LoginPage(page); + variablesPage = new VariablesPage(page); + + await loginPage.login(); + await variablesPage.goto(); + + console.log(`Cleaning up ${testVariables.length} test variables...`); + + // Delete in batches to avoid overwhelming the UI + const batchSize = 10; + for (let i = 0; i < testVariables.length; i += batchSize) { + const batch = testVariables.slice(i, i + batchSize); + const existingKeys: string[] = []; + + // Check which variables still exist + for (const key of batch) { + const exists = await variablesPage.isVariableVisible(key); + if (exists) { + existingKeys.push(key); + } + } + + // Delete existing variables + if (existingKeys.length > 0) { + try { + await variablesPage.deleteMultipleVariables(existingKeys); + } catch (error) { + console.error(`Failed to delete batch: ${error}`); + } + } + } + + console.log("Cleanup complete"); + await context.close(); + }); + + test.beforeEach(async ({ page }) => { + loginPage = new LoginPage(page); + variablesPage = new VariablesPage(page); + + await loginPage.login(); + await variablesPage.goto(); + }); + + test("should display variables list", async () => { + // Verify the table is visible + const isVisible = await variablesPage.isVisible('[role="table"]'); + expect(isVisible).toBe(true); + + // Verify we have variables (from beforeAll) + const count = await variablesPage.getDisplayedVariablesCount(); + expect(count).toBeGreaterThan(0); + }); + + test("should create a new variable", async () => { + const key = generateTestKey("create_test"); + const value = "test_value_create"; + const description = "Created via E2E test"; + + await variablesPage.createVariable(key, value, description); + + // Verify the variable appears in the table + await variablesPage.verifyVariableExists(key, value, description); + }); + + test("should create an encrypted variable", async () => { + const key = generateTestKey("encrypted_test"); + const value = "secret_value"; + const description = "Encrypted variable"; + + await variablesPage.createVariable(key, value, description, true); + + // Verify the variable exists + const isVisible = await variablesPage.isVariableVisible(key); + expect(isVisible).toBe(true); + + // Verify it's marked as encrypted + const data = await variablesPage.getVariableData(key); + expect(data?.isEncrypted).toBeTruthy(); + }); + + test("should edit a variable", async () => { + // Create a variable to edit + const key = generateTestKey("edit_test"); + await variablesPage.createVariable(key, "original_value", "Original description"); + + // Edit the variable + const newValue = "updated_value"; + const newDescription = "Updated description"; + await variablesPage.editVariable(key, newValue, newDescription); + + // Verify the updates + await variablesPage.verifyVariableExists(key, newValue, newDescription); + }); + + test("should delete a single variable", async () => { + // Create a variable to delete + const key = generateTestKey("delete_test"); + await variablesPage.createVariable(key, "value_to_delete", "Will be deleted"); + + // Verify it exists + let isVisible = await variablesPage.isVariableVisible(key); + expect(isVisible).toBe(true); + + // Delete it + await variablesPage.deleteVariable(key); + + // Verify it's gone + await variablesPage.verifyVariableNotExists(key); + + // Remove from cleanup list + const index = testVariables.indexOf(key); + if (index > -1) { + testVariables.splice(index, 1); + } + }); + + test("should delete multiple variables", async () => { + // Create multiple variables + const keys = [ + generateTestKey("multi_delete_1"), + generateTestKey("multi_delete_2"), + generateTestKey("multi_delete_3"), + ]; + + for (const key of keys) { + await variablesPage.createVariable(key, `value_${key}`, "To be deleted"); + } + + // Verify they all exist + for (const key of keys) { + const isVisible = await variablesPage.isVariableVisible(key); + expect(isVisible).toBe(true); + } + + // Delete all of them + await variablesPage.deleteMultipleVariables(keys); + + // Verify they're all gone + for (const key of keys) { + await variablesPage.verifyVariableNotExists(key); + } + + // Remove from cleanup list + keys.forEach((key) => { + const index = testVariables.indexOf(key); + if (index > -1) { + testVariables.splice(index, 1); + } + }); + }); + + test("should search variables by key pattern", async () => { + // Create a unique variable for this test + const uniqueKey = generateTestKey("search_unique"); + await variablesPage.createVariable(uniqueKey, "searchable_value", "For search test"); + + // Search for the unique key + await variablesPage.searchVariables(uniqueKey); + + // Verify only the searched variable is visible + const isVisible = await variablesPage.isVariableVisible(uniqueKey); + expect(isVisible).toBe(true); + + // Clear search + await variablesPage.clearSearch(); + + // Verify we see more variables again + const count = await variablesPage.getDisplayedVariablesCount(); + expect(count).toBeGreaterThan(1); + }); + + test("should search variables with wildcard pattern", async () => { + // Create variables with common prefix + const prefix = generateTestKey("wildcard"); + const keys = [ + `${prefix}_one`, + `${prefix}_two`, + `${prefix}_three`, + ]; + + // Track for cleanup + keys.forEach((key) => testVariables.push(key)); + + for (const key of keys) { + await variablesPage.createVariable(key, `value_${key}`, "Wildcard test"); + } + + // Search using partial pattern + await variablesPage.searchVariables(prefix); + + // Verify all matching variables are visible + for (const key of keys) { + const isVisible = await variablesPage.isVariableVisible(key); + expect(isVisible).toBe(true); + } + + // Clear search + await variablesPage.clearSearch(); + }); + + test("should import variables from JSON file", async ({ page }) => { + // Create a temporary JSON file with test variables + const importData: Record = {}; + const importKeys: string[] = []; + + for (let i = 1; i <= 3; i++) { + const key = generateTestKey(`import_${i}`); + importKeys.push(key); + importData[key] = `imported_value_${i}`; + } + + const tempFile = path.join(__dirname, `test_import_${Date.now()}.json`); + fs.writeFileSync(tempFile, JSON.stringify(importData, null, 2)); + + try { + // Import the file + await variablesPage.importVariables(tempFile); + + // Verify all imported variables exist + for (const key of importKeys) { + const isVisible = await variablesPage.isVariableVisible(key); + expect(isVisible).toBe(true); + } + } finally { + // Cleanup temp file + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } + }); + + test("should verify pagination works", async () => { + // With 35+ test variables, we should have multiple pages (30 per page) + const totalVars = await variablesPage.getTotalVariables(); + expect(totalVars).toBeGreaterThan(30); + + // Verify we're on page 1 + let currentPage = await variablesPage.getCurrentPage(); + expect(currentPage).toBe(1); + + // Go to next page + await variablesPage.goToNextPage(); + + // Verify we're on page 2 + currentPage = await variablesPage.getCurrentPage(); + expect(currentPage).toBe(2); + + // Go back to page 1 + await variablesPage.goToPreviousPage(); + + // Verify we're back on page 1 + currentPage = await variablesPage.getCurrentPage(); + expect(currentPage).toBe(1); + }); + + test("should sort variables by key (ascending)", async () => { + // Click the Key column header + await variablesPage.sortByColumn("Key"); + + // Wait a moment for sort to apply + await variablesPage.wait(500); + + // Get the sort direction (this might vary based on implementation) + // Just verify that sorting action completed without error + const displayedCount = await variablesPage.getDisplayedVariablesCount(); + expect(displayedCount).toBeGreaterThan(0); + }); + + test("should sort variables by key (descending)", async () => { + // Click the Key column header once + await variablesPage.sortByColumn("Key"); + await variablesPage.wait(500); + + // Click again for descending + await variablesPage.sortByColumn("Key"); + await variablesPage.wait(500); + + // Verify variables are still displayed + const displayedCount = await variablesPage.getDisplayedVariablesCount(); + expect(displayedCount).toBeGreaterThan(0); + }); + + test("should sort variables by value", async () => { + // Click the Value column header + await variablesPage.sortByColumn("Value"); + await variablesPage.wait(500); + + // Verify variables are still displayed + const displayedCount = await variablesPage.getDisplayedVariablesCount(); + expect(displayedCount).toBeGreaterThan(0); + }); + + test("should sort variables by description", async () => { + // Click the Description column header + await variablesPage.sortByColumn("Description"); + await variablesPage.wait(500); + + // Verify variables are still displayed + const displayedCount = await variablesPage.getDisplayedVariablesCount(); + expect(displayedCount).toBeGreaterThan(0); + }); +}); diff --git a/airflow/ui/tests/e2e/testConfig.ts b/airflow/ui/tests/e2e/testConfig.ts new file mode 100644 index 0000000000000..8a4ce8e5609f3 --- /dev/null +++ b/airflow/ui/tests/e2e/testConfig.ts @@ -0,0 +1,31 @@ +/*! + * 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. + */ + +export const testConfig = { + baseUrl: process.env.AIRFLOW_BASE_URL || "http://localhost:8080", + username: process.env.AIRFLOW_USERNAME || "admin", + password: process.env.AIRFLOW_PASSWORD || "admin", + testDataPrefix: "e2e_test_", + defaultTimeout: 30000, + navigationTimeout: 60000, + paths: { + login: "/login", + variables: "/ui/variables", + }, +};