vatsrahul1001 commented on code in PR #60738: URL: https://github.com/apache/airflow/pull/60738#discussion_r2727125337
########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,506 @@ +/*! + * 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, type Locator, type Page } from "@playwright/test"; +import { BasePage } from "tests/e2e/pages/BasePage"; + +type ConnectionDetails = { + conn_type: string; + connection_id: string; + description?: string; + extra?: string; + host?: string; + login?: string; + password?: string; + port?: number | string; + schema?: string; +}; + +export class ConnectionsPage extends BasePage { + // Page URLs + public static get connectionsListUrl(): string { + return "/connections"; + } + + public readonly addButton: Locator; + public readonly cancelDeleteButton: Locator; + public readonly confirmDeleteButton: 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 hostHeader: Locator; + public readonly hostInput: Locator; + public readonly loginInput: Locator; + + // Pagination elements + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + public readonly passwordInput: Locator; + + public readonly portInput: Locator; + public readonly rowsPerPageSelect: Locator; + public readonly saveButton: Locator; + + 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'); + + // Action buttons + this.addButton = page.getByRole("button", { name: "Add Connection" }); + this.testConnectionButton = page.locator('button:has-text("Test")'); + this.saveButton = page.getByRole("button", { name: /^save$/i }); + + // Form inputs (Chakra UI inputs) + this.connectionIdInput = page.locator('input[name="connection_id"]').first(); + this.connectionTypeSelect = page.getByRole("combobox").first(); + this.hostInput = page.locator('input[name="host"]').first(); + this.portInput = page.locator('input[name="port"]').first(); + this.loginInput = page.locator('input[name="login"]').first(); + this.passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + this.schemaInput = page.locator('input[name="schema"]').first(); + // Try multiple possible selectors + this.descriptionInput = page.locator('[name="description"]').first(); + + // Alerts + this.successAlert = page.locator('[data-scope="toast"][data-part="root"]'); + + // Delete confirmation dialog + this.confirmDeleteButton = page.locator('button:has-text("Delete")').first(); + this.cancelDeleteButton = page.locator('button:[aria-label*="Cancel"]').first(); + + // Pagination + this.paginationNextButton = page.locator('button[aria-label*="next page"]').first(); + this.paginationPrevButton = page.locator('button[aria-label*="previous page"]').first(); + this.rowsPerPageSelect = page.locator("select"); + + // Sorting and filtering + this.tableHeader = page.locator('[role="columnheader"]').first(); + this.connectionIdHeader = page.locator('[role="columnheader"]:has-text("Connection ID")').first(); + this.connectionTypeHeader = page.locator('[role="columnheader"]:has-text("Connection Type")').first(); + this.hostHeader = page.locator('[role="columnheader"]:has-text("Host")').first(); + this.searchInput = page.locator('input[placeholder*="Search"], input[placeholder*="search"]').first(); + } + + // Click the Add button to create a new connection + public async clickAddButton(): Promise<void> { + await expect(this.addButton).toBeVisible({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await this.page.waitForTimeout(1000); + } + + // Click edit button for a specific connection + public async clickEditButton(connectionId: string): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + const editButton = row.getByRole("button", { name: "Edit Connection" }); + + await expect(editButton).toBeVisible({ timeout: 2000 }); + await editButton.click(); + await this.page.waitForTimeout(500); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationNextButton).toBeVisible({ timeout: 5000 }); + await this.paginationNextButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Next page loaded"); + } + + // Click next page button for pagination + public async clickPrevPage(): Promise<void> { + const isEnabled = await this.paginationPrevButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationPrevButton).toBeVisible({ timeout: 5000 }); + await this.paginationPrevButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Previous page loaded"); + } + + // Check if a connection exists in the current view + public async connectionExists(connectionId: string): Promise<boolean> { + const emptyState = await this.page + .locator("text=No connection found!") + .isVisible({ timeout: 1000 }) + .catch(() => false); + + if (emptyState) { + console.log("Empty state detected - no connections exist"); + + return false; + } + const row = await this.findConnectionRow(connectionId); + const visible = row !== null; + + return visible; + } + + // Create a new connection with full workflow + public async createConnection(details: ConnectionDetails): Promise<void> { + await this.clickAddButton(); + await this.fillConnectionForm(details); + await this.saveConnection(details.connection_id); + console.log(`✓ Connection ${details.connection_id} created`); + } + + // Delete a connection by connection ID + public async deleteConnection(connectionId: string): Promise<void> { + // await this.navigate(); + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + // Find delete button in the row + await this.page.evaluate(() => { + const backdrops = document.querySelectorAll<HTMLElement>('[data-scope="dialog"][data-part="backdrop"]'); + + backdrops.forEach((backdrop) => { + const { state } = backdrop.dataset; + + if (state === "closed") { + console.log("Removing stuck closed backdrop"); + backdrop.remove(); + } + }); + }); + const deleteButton = row.getByRole("button", { name: "Delete Connection" }); + + await expect(deleteButton).toBeVisible({ timeout: 1000 }); + await deleteButton.click(); + + console.log("Waiting for delete confirmation dialog..."); + + // Wait for confirmation dialog + await this.page.waitForTimeout(500); + + // Handle delete confirmation - try multiple button texts + await expect(this.confirmDeleteButton).toBeVisible({ timeout: 2000 }); + await this.confirmDeleteButton.click(); + + // Wait a moment for backend to process + await this.page.waitForTimeout(1000); Review Comment: Lets not use wait. Use explicit element waits or expect.poll() for state changes. ########## airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts: ########## @@ -0,0 +1,563 @@ +/*! + * 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, AUTH_FILE } from "playwright.config"; +import { ConnectionsPage } from "tests/e2e/pages/ConnectionsPage"; + +test.describe("Connections Page - List and Display", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.post(`${baseUrl}/api/v2/connections`, { + data: { + conn_type: "http", + connection_id: "list_seed_conn", + host: "seed.example.com", + }, + }); + }); + + test.afterAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.delete(`${baseUrl}/api/v2/connections/list_seed_conn`); + }); + + test("should display connections list page", async () => { + await connectionsPage.navigate(); + + // Verify the page is loaded + expect(connectionsPage.page.url()).toContain("/connections"); + + // Verify table or list is visible + expect(await connectionsPage.connectionsTable.isVisible()).toBeTruthy(); + }); + + test("should display connections with correct columns", async () => { + await connectionsPage.navigate(); + + // Check that we have at least one row + const count = await connectionsPage.getConnectionCount(); + + expect(count).toBeGreaterThanOrEqual(0); + + if (count > 0) { + // Verify connections are listed with expected information + const connectionIds = await connectionsPage.getConnectionIds(); + + expect(connectionIds.length).toBeGreaterThan(0); + } + }); + + test("should have Add button visible", async () => { + await connectionsPage.navigate(); + expect(await connectionsPage.addButton.isVisible()).toBeTruthy(); + }); +}); + +test.describe("Connections Page - CRUD Operations", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Test connection details - using dynamic data + const testConnection = { + conn_type: "postgres", // Adjust based on available connection types in your Airflow instance + connection_id: `atest_conn_${timestamp}`, + description: `Test connection created at ${new Date().toISOString()}`, + extra: JSON.stringify({ + options: "-c statement_timeout=5000", + sslmode: "require", + }), + host: `test-host-${timestamp}.example.com`, + login: `test_user_${timestamp}`, + password: `test_password_${timestamp}`, + port: 5432, + schema: "test_db", + }; + + const updatedConnection = { + description: `Updated test connection at ${new Date().toISOString()}`, + host: `updated-host-${timestamp}.example.com`, + login: `updated_user_${timestamp}`, + port: 5433, + }; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.afterAll(async ({ browser }) => { + // Cleanup: Delete test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + // Delete the test connection + const deleteResponse = await page.request.delete( + `${baseUrl}/api/v2/connections/${testConnection.connection_id}`, + ); + + expect([204, 404]).toContain(deleteResponse.status()); + }); + + test("should create a new connection with all fields", async () => { + await connectionsPage.navigate(); + + // Click add button + await connectionsPage.createConnection(testConnection); + // Verify the connection was created + const exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + }); + + test("should display created connection in list with correct type", async () => { + await connectionsPage.navigate(); + + // Verify the connection is visible with correct details + await connectionsPage.verifyConnectionInList(testConnection.connection_id, testConnection.conn_type); + }); + + test("should edit an existing connection", async () => { + await connectionsPage.navigate(); + + // Verify connection exists before editing + let exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Edit the connection + await connectionsPage.editConnection(testConnection.connection_id, updatedConnection); + + // Verify the connection was updated + // await connectionsPage.navigate(); + exists = await connectionsPage.connectionExists(testConnection.connection_id); + expect(exists).toBeTruthy(); + }); + + test("should delete a connection", async () => { + // Create a temporary connection for deletion test + const tempConnection = { + conn_type: "postgres", + connection_id: `temp_conn_${timestamp}_delete`, + host: `temp-host-${timestamp}.example.com`, + login: "temp_user", + password: "temp_password", + }; + + await connectionsPage.navigate(); + await connectionsPage.createConnection(tempConnection); + let exists = await connectionsPage.connectionExists(tempConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Delete the connection + await connectionsPage.deleteConnection(tempConnection.connection_id); + exists = await connectionsPage.connectionExists(tempConnection.connection_id); + expect(exists).toBeFalsy(); + }); +}); + +test.describe("Connections Page - Pagination", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Create multiple test connections to ensure we have enough for pagination testing + const testConnections = Array.from({ length: 5 }, (_, i) => ({ + conn_type: "http", + connection_id: `pagination_test_${timestamp}_${i}`, + host: `pagination-host-${i}.example.com`, + login: `pagination_user_${i}`, + })); + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create multiple test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + // Connection may already exist + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup all test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should display pagination controls when applicable", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + expect(typeof hasPagination).toBe("boolean"); + }); + + test("should navigate to next page and verify data changes", async () => { + await connectionsPage.navigate(); + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (hasPagination) { + const initialIds = await connectionsPage.getConnectionIds(); + + expect(initialIds.length).toBeGreaterThan(0); + + // Check if next button is enabled + const nextButtonEnabled = await connectionsPage.paginationNextButton + .isEnabled({ timeout: 2000 }) + .catch(() => false); + + if (nextButtonEnabled) { + await connectionsPage.clickNextPage(); + const newIds = await connectionsPage.getConnectionIds(); + + // Verify we have connections on the new page + expect(newIds.length).toBeGreaterThan(0); + } + } + }); + + test("should navigate to previous page and return to original", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (hasPagination) { + const nextButtonEnabled = await connectionsPage.paginationNextButton + .isEnabled({ timeout: 2000 }) + .catch(() => false); + + if (nextButtonEnabled) { + await connectionsPage.clickNextPage(); + + // Check if previous button is enabled + const prevButtonEnabled = await connectionsPage.paginationPrevButton + .isEnabled({ timeout: 2000 }) + .catch(() => false); + + if (prevButtonEnabled) { + // Go back to first page + await connectionsPage.clickPrevPage(); + const returnedIds = await connectionsPage.getConnectionIds(); + + expect(returnedIds.length).toBeGreaterThan(0); + } + } + } + }); +}); + +test.describe("Connections Page - Sorting", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Create test connections with distinct names for sorting + const sortTestConnections = [ + { + conn_type: "http", + connection_id: `z_sort_conn_${timestamp}`, + host: "z-host.example.com", + login: "z_user", + }, + { + conn_type: "postgres", + connection_id: `a_sort_conn_${timestamp}`, + host: "a-host.example.com", + login: "a_user", + }, + { + conn_type: "mysql", + connection_id: `m_sort_conn_${timestamp}`, + host: "m-host.example.com", + login: "m_user", + }, + ]; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of sortTestConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of sortTestConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should sort by Connection Type when clicking header", async () => { + await connectionsPage.navigate(); + + // Click to sort (first click should be ascending) + await connectionsPage.sortByHeader("Connection ID"); + const idsAfter = await connectionsPage.getConnectionIds(); + + // Verify it's actually sorted (case-insensitive) + const sortedIds = [...idsAfter].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + expect(idsAfter).toEqual(sortedIds); + }); + + test("should toggle sort order when clicking header twice", async () => { + await connectionsPage.navigate(); + + // First click + await connectionsPage.sortByHeader("Connection ID"); + const idsAsc = await connectionsPage.getConnectionIds(); + + expect(idsAsc.length).toBeGreaterThan(0); + + // Verify it's sorted ascending + let isAscending = true; + + for (let i = 0; i < idsAsc.length - 1; i++) { + const current = idsAsc[i]; + const next = idsAsc[i + 1]; + + // TypeScript safety check + if (current === undefined || current === "" || next === undefined || next === "") continue; + + if (current.toLowerCase() > next.toLowerCase()) { + isAscending = false; + break; + } + } + expect(isAscending).toBe(true); + + // Second click + await connectionsPage.sortByHeader("Connection ID"); + const idsDesc = await connectionsPage.getConnectionIds(); + + expect(idsDesc.length).toBeGreaterThan(0); + + // Verify it's sorted descending + let isDescending = true; + + for (let i = 0; i < idsDesc.length - 1; i++) { + const current = idsDesc[i]; + const next = idsDesc[i + 1]; + + // TypeScript safety check + if (current === undefined || current === "" || next === undefined || next === "") continue; + if (current.toLowerCase() < next.toLowerCase()) { + isDescending = false; + break; + } + } + expect(isDescending).toBe(true); + }); + + test("should keep each page sorted after navigating", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (!hasPagination) { + test.skip(); + + return; + } + + // Sort first page + await connectionsPage.sortByHeader("Connection ID"); + const firstPageIds = await connectionsPage.getConnectionIds(); + + if (firstPageIds.length === 0) { + test.skip(); + + return; + } + + const nextButtonEnabled = await connectionsPage.paginationNextButton.isEnabled().catch(() => false); + + if (!nextButtonEnabled) { + test.skip(); + + return; + } + + // Navigate to next page + await connectionsPage.clickNextPage(); + const secondPageIds = await connectionsPage.getConnectionIds(); + + expect(secondPageIds.length).toBeGreaterThan(0); + + // Verify second page is ALSO sorted (this is what matters!) + const secondPageSorted = secondPageIds.every((id, i) => { + if (i === secondPageIds.length - 1) return true; + const nextId = secondPageIds[i + 1]; + + return nextId !== undefined && id.toLowerCase() <= nextId.toLowerCase(); + }); + + expect(secondPageSorted).toBe(true); + }); +}); + +test.describe("Connections Page - Search and Filter", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + const searchTestConnections = [ + { + conn_type: "postgres", + connection_id: `search_production_${timestamp}`, + host: "prod-db.example.com", + login: "prod_user", + }, + { + conn_type: "mysql", + connection_id: `search_staging_${timestamp}`, + host: "staging-db.example.com", + login: "staging_user", + }, + { + conn_type: "http", + connection_id: `search_development_${timestamp}`, + host: "dev-api.example.com", + login: "dev_user", + }, + ]; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of searchTestConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of searchTestConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should filter connections by search term", async () => { + await connectionsPage.navigate(); + + // Try to search for a specific connection + try { + const searchTerm = "production"; + + await connectionsPage.searchConnections(searchTerm); + const ids = await connectionsPage.getConnectionIds(); + + // Should have at least the test connection with 'production' in the name + if (ids.length > 0) { + expect(ids.length).toBeGreaterThan(-1); + } + } catch { + // Search might not be implemented + await connectionsPage.navigate(); Review Comment: If search fails, test passes silently. Should either work or fail explicitly. ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,506 @@ +/*! + * 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, type Locator, type Page } from "@playwright/test"; +import { BasePage } from "tests/e2e/pages/BasePage"; + +type ConnectionDetails = { + conn_type: string; + connection_id: string; + description?: string; + extra?: string; + host?: string; + login?: string; + password?: string; + port?: number | string; + schema?: string; +}; + +export class ConnectionsPage extends BasePage { + // Page URLs + public static get connectionsListUrl(): string { + return "/connections"; + } + + public readonly addButton: Locator; + public readonly cancelDeleteButton: Locator; + public readonly confirmDeleteButton: 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 hostHeader: Locator; + public readonly hostInput: Locator; + public readonly loginInput: Locator; + + // Pagination elements + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + public readonly passwordInput: Locator; + + public readonly portInput: Locator; + public readonly rowsPerPageSelect: Locator; + public readonly saveButton: Locator; + + 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'); + + // Action buttons + this.addButton = page.getByRole("button", { name: "Add Connection" }); + this.testConnectionButton = page.locator('button:has-text("Test")'); + this.saveButton = page.getByRole("button", { name: /^save$/i }); + + // Form inputs (Chakra UI inputs) + this.connectionIdInput = page.locator('input[name="connection_id"]').first(); + this.connectionTypeSelect = page.getByRole("combobox").first(); + this.hostInput = page.locator('input[name="host"]').first(); + this.portInput = page.locator('input[name="port"]').first(); + this.loginInput = page.locator('input[name="login"]').first(); + this.passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + this.schemaInput = page.locator('input[name="schema"]').first(); + // Try multiple possible selectors + this.descriptionInput = page.locator('[name="description"]').first(); + + // Alerts + this.successAlert = page.locator('[data-scope="toast"][data-part="root"]'); + + // Delete confirmation dialog + this.confirmDeleteButton = page.locator('button:has-text("Delete")').first(); + this.cancelDeleteButton = page.locator('button:[aria-label*="Cancel"]').first(); + + // Pagination + this.paginationNextButton = page.locator('button[aria-label*="next page"]').first(); + this.paginationPrevButton = page.locator('button[aria-label*="previous page"]').first(); + this.rowsPerPageSelect = page.locator("select"); + + // Sorting and filtering + this.tableHeader = page.locator('[role="columnheader"]').first(); + this.connectionIdHeader = page.locator('[role="columnheader"]:has-text("Connection ID")').first(); + this.connectionTypeHeader = page.locator('[role="columnheader"]:has-text("Connection Type")').first(); + this.hostHeader = page.locator('[role="columnheader"]:has-text("Host")').first(); + this.searchInput = page.locator('input[placeholder*="Search"], input[placeholder*="search"]').first(); + } + + // Click the Add button to create a new connection + public async clickAddButton(): Promise<void> { + await expect(this.addButton).toBeVisible({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await this.page.waitForTimeout(1000); + } + + // Click edit button for a specific connection + public async clickEditButton(connectionId: string): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + const editButton = row.getByRole("button", { name: "Edit Connection" }); + + await expect(editButton).toBeVisible({ timeout: 2000 }); + await editButton.click(); + await this.page.waitForTimeout(500); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationNextButton).toBeVisible({ timeout: 5000 }); + await this.paginationNextButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Next page loaded"); + } + + // Click next page button for pagination + public async clickPrevPage(): Promise<void> { + const isEnabled = await this.paginationPrevButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationPrevButton).toBeVisible({ timeout: 5000 }); + await this.paginationPrevButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Previous page loaded"); + } + + // Check if a connection exists in the current view + public async connectionExists(connectionId: string): Promise<boolean> { + const emptyState = await this.page + .locator("text=No connection found!") + .isVisible({ timeout: 1000 }) + .catch(() => false); + + if (emptyState) { + console.log("Empty state detected - no connections exist"); Review Comment: Remove all console.log statements - tests should use assertions, not logs. ########## airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts: ########## @@ -0,0 +1,563 @@ +/*! + * 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, AUTH_FILE } from "playwright.config"; +import { ConnectionsPage } from "tests/e2e/pages/ConnectionsPage"; + +test.describe("Connections Page - List and Display", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.post(`${baseUrl}/api/v2/connections`, { + data: { + conn_type: "http", + connection_id: "list_seed_conn", + host: "seed.example.com", + }, + }); + }); + + test.afterAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.delete(`${baseUrl}/api/v2/connections/list_seed_conn`); + }); + + test("should display connections list page", async () => { + await connectionsPage.navigate(); + + // Verify the page is loaded + expect(connectionsPage.page.url()).toContain("/connections"); + + // Verify table or list is visible + expect(await connectionsPage.connectionsTable.isVisible()).toBeTruthy(); + }); + + test("should display connections with correct columns", async () => { + await connectionsPage.navigate(); + + // Check that we have at least one row + const count = await connectionsPage.getConnectionCount(); + + expect(count).toBeGreaterThanOrEqual(0); + + if (count > 0) { + // Verify connections are listed with expected information + const connectionIds = await connectionsPage.getConnectionIds(); + + expect(connectionIds.length).toBeGreaterThan(0); + } + }); + + test("should have Add button visible", async () => { + await connectionsPage.navigate(); + expect(await connectionsPage.addButton.isVisible()).toBeTruthy(); + }); +}); + +test.describe("Connections Page - CRUD Operations", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Test connection details - using dynamic data + const testConnection = { + conn_type: "postgres", // Adjust based on available connection types in your Airflow instance + connection_id: `atest_conn_${timestamp}`, + description: `Test connection created at ${new Date().toISOString()}`, + extra: JSON.stringify({ + options: "-c statement_timeout=5000", + sslmode: "require", + }), + host: `test-host-${timestamp}.example.com`, + login: `test_user_${timestamp}`, + password: `test_password_${timestamp}`, + port: 5432, + schema: "test_db", + }; + + const updatedConnection = { + description: `Updated test connection at ${new Date().toISOString()}`, + host: `updated-host-${timestamp}.example.com`, + login: `updated_user_${timestamp}`, + port: 5433, + }; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.afterAll(async ({ browser }) => { + // Cleanup: Delete test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + // Delete the test connection + const deleteResponse = await page.request.delete( + `${baseUrl}/api/v2/connections/${testConnection.connection_id}`, + ); + + expect([204, 404]).toContain(deleteResponse.status()); + }); + + test("should create a new connection with all fields", async () => { + await connectionsPage.navigate(); + + // Click add button + await connectionsPage.createConnection(testConnection); + // Verify the connection was created + const exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + }); + + test("should display created connection in list with correct type", async () => { + await connectionsPage.navigate(); + + // Verify the connection is visible with correct details + await connectionsPage.verifyConnectionInList(testConnection.connection_id, testConnection.conn_type); + }); + + test("should edit an existing connection", async () => { + await connectionsPage.navigate(); + + // Verify connection exists before editing + let exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Edit the connection + await connectionsPage.editConnection(testConnection.connection_id, updatedConnection); + + // Verify the connection was updated + // await connectionsPage.navigate(); + exists = await connectionsPage.connectionExists(testConnection.connection_id); + expect(exists).toBeTruthy(); + }); + + test("should delete a connection", async () => { + // Create a temporary connection for deletion test + const tempConnection = { + conn_type: "postgres", + connection_id: `temp_conn_${timestamp}_delete`, + host: `temp-host-${timestamp}.example.com`, + login: "temp_user", + password: "temp_password", + }; + + await connectionsPage.navigate(); + await connectionsPage.createConnection(tempConnection); + let exists = await connectionsPage.connectionExists(tempConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Delete the connection + await connectionsPage.deleteConnection(tempConnection.connection_id); + exists = await connectionsPage.connectionExists(tempConnection.connection_id); + expect(exists).toBeFalsy(); + }); +}); + +test.describe("Connections Page - Pagination", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Create multiple test connections to ensure we have enough for pagination testing + const testConnections = Array.from({ length: 5 }, (_, i) => ({ + conn_type: "http", + connection_id: `pagination_test_${timestamp}_${i}`, + host: `pagination-host-${i}.example.com`, + login: `pagination_user_${i}`, + })); + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create multiple test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + // Connection may already exist + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup all test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should display pagination controls when applicable", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + expect(typeof hasPagination).toBe("boolean"); + }); + + test("should navigate to next page and verify data changes", async () => { + await connectionsPage.navigate(); + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (hasPagination) { + const initialIds = await connectionsPage.getConnectionIds(); + + expect(initialIds.length).toBeGreaterThan(0); + + // Check if next button is enabled + const nextButtonEnabled = await connectionsPage.paginationNextButton + .isEnabled({ timeout: 2000 }) + .catch(() => false); + + if (nextButtonEnabled) { + await connectionsPage.clickNextPage(); + const newIds = await connectionsPage.getConnectionIds(); + + // Verify we have connections on the new page + expect(newIds.length).toBeGreaterThan(0); + } + } + }); + + test("should navigate to previous page and return to original", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (hasPagination) { + const nextButtonEnabled = await connectionsPage.paginationNextButton + .isEnabled({ timeout: 2000 }) + .catch(() => false); + + if (nextButtonEnabled) { + await connectionsPage.clickNextPage(); + + // Check if previous button is enabled + const prevButtonEnabled = await connectionsPage.paginationPrevButton + .isEnabled({ timeout: 2000 }) + .catch(() => false); + + if (prevButtonEnabled) { + // Go back to first page + await connectionsPage.clickPrevPage(); + const returnedIds = await connectionsPage.getConnectionIds(); + + expect(returnedIds.length).toBeGreaterThan(0); + } + } + } + }); +}); + +test.describe("Connections Page - Sorting", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Create test connections with distinct names for sorting + const sortTestConnections = [ + { + conn_type: "http", + connection_id: `z_sort_conn_${timestamp}`, + host: "z-host.example.com", + login: "z_user", + }, + { + conn_type: "postgres", + connection_id: `a_sort_conn_${timestamp}`, + host: "a-host.example.com", + login: "a_user", + }, + { + conn_type: "mysql", + connection_id: `m_sort_conn_${timestamp}`, + host: "m-host.example.com", + login: "m_user", + }, + ]; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of sortTestConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of sortTestConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should sort by Connection Type when clicking header", async () => { + await connectionsPage.navigate(); + + // Click to sort (first click should be ascending) + await connectionsPage.sortByHeader("Connection ID"); + const idsAfter = await connectionsPage.getConnectionIds(); + + // Verify it's actually sorted (case-insensitive) + const sortedIds = [...idsAfter].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + expect(idsAfter).toEqual(sortedIds); + }); + + test("should toggle sort order when clicking header twice", async () => { + await connectionsPage.navigate(); + + // First click + await connectionsPage.sortByHeader("Connection ID"); + const idsAsc = await connectionsPage.getConnectionIds(); + + expect(idsAsc.length).toBeGreaterThan(0); + + // Verify it's sorted ascending + let isAscending = true; + + for (let i = 0; i < idsAsc.length - 1; i++) { + const current = idsAsc[i]; + const next = idsAsc[i + 1]; + + // TypeScript safety check + if (current === undefined || current === "" || next === undefined || next === "") continue; + + if (current.toLowerCase() > next.toLowerCase()) { + isAscending = false; + break; + } + } + expect(isAscending).toBe(true); + + // Second click + await connectionsPage.sortByHeader("Connection ID"); + const idsDesc = await connectionsPage.getConnectionIds(); + + expect(idsDesc.length).toBeGreaterThan(0); + + // Verify it's sorted descending + let isDescending = true; + + for (let i = 0; i < idsDesc.length - 1; i++) { + const current = idsDesc[i]; + const next = idsDesc[i + 1]; + + // TypeScript safety check + if (current === undefined || current === "" || next === undefined || next === "") continue; + if (current.toLowerCase() < next.toLowerCase()) { + isDescending = false; + break; + } + } + expect(isDescending).toBe(true); + }); + + test("should keep each page sorted after navigating", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (!hasPagination) { + test.skip(); + + return; + } + + // Sort first page + await connectionsPage.sortByHeader("Connection ID"); + const firstPageIds = await connectionsPage.getConnectionIds(); + + if (firstPageIds.length === 0) { + test.skip(); + + return; + } + + const nextButtonEnabled = await connectionsPage.paginationNextButton.isEnabled().catch(() => false); + + if (!nextButtonEnabled) { + test.skip(); + + return; + } + + // Navigate to next page + await connectionsPage.clickNextPage(); + const secondPageIds = await connectionsPage.getConnectionIds(); + + expect(secondPageIds.length).toBeGreaterThan(0); + + // Verify second page is ALSO sorted (this is what matters!) + const secondPageSorted = secondPageIds.every((id, i) => { + if (i === secondPageIds.length - 1) return true; + const nextId = secondPageIds[i + 1]; + + return nextId !== undefined && id.toLowerCase() <= nextId.toLowerCase(); + }); + + expect(secondPageSorted).toBe(true); + }); +}); + +test.describe("Connections Page - Search and Filter", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + const searchTestConnections = [ + { + conn_type: "postgres", + connection_id: `search_production_${timestamp}`, + host: "prod-db.example.com", + login: "prod_user", + }, + { + conn_type: "mysql", + connection_id: `search_staging_${timestamp}`, + host: "staging-db.example.com", + login: "staging_user", + }, + { + conn_type: "http", + connection_id: `search_development_${timestamp}`, + host: "dev-api.example.com", + login: "dev_user", + }, + ]; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of searchTestConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of searchTestConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should filter connections by search term", async () => { + await connectionsPage.navigate(); + + // Try to search for a specific connection + try { + const searchTerm = "production"; + + await connectionsPage.searchConnections(searchTerm); + const ids = await connectionsPage.getConnectionIds(); + + // Should have at least the test connection with 'production' in the name + if (ids.length > 0) { + expect(ids.length).toBeGreaterThan(-1); Review Comment: This will always return true. ########## airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts: ########## @@ -0,0 +1,563 @@ +/*! + * 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, AUTH_FILE } from "playwright.config"; +import { ConnectionsPage } from "tests/e2e/pages/ConnectionsPage"; + +test.describe("Connections Page - List and Display", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.post(`${baseUrl}/api/v2/connections`, { + data: { + conn_type: "http", + connection_id: "list_seed_conn", + host: "seed.example.com", + }, + }); + }); + + test.afterAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.delete(`${baseUrl}/api/v2/connections/list_seed_conn`); + }); + + test("should display connections list page", async () => { + await connectionsPage.navigate(); + + // Verify the page is loaded + expect(connectionsPage.page.url()).toContain("/connections"); + + // Verify table or list is visible + expect(await connectionsPage.connectionsTable.isVisible()).toBeTruthy(); + }); + + test("should display connections with correct columns", async () => { + await connectionsPage.navigate(); + + // Check that we have at least one row + const count = await connectionsPage.getConnectionCount(); + + expect(count).toBeGreaterThanOrEqual(0); + + if (count > 0) { + // Verify connections are listed with expected information + const connectionIds = await connectionsPage.getConnectionIds(); + + expect(connectionIds.length).toBeGreaterThan(0); + } + }); + + test("should have Add button visible", async () => { + await connectionsPage.navigate(); + expect(await connectionsPage.addButton.isVisible()).toBeTruthy(); + }); +}); + +test.describe("Connections Page - CRUD Operations", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Test connection details - using dynamic data + const testConnection = { + conn_type: "postgres", // Adjust based on available connection types in your Airflow instance + connection_id: `atest_conn_${timestamp}`, + description: `Test connection created at ${new Date().toISOString()}`, + extra: JSON.stringify({ + options: "-c statement_timeout=5000", + sslmode: "require", + }), + host: `test-host-${timestamp}.example.com`, + login: `test_user_${timestamp}`, + password: `test_password_${timestamp}`, + port: 5432, + schema: "test_db", + }; + + const updatedConnection = { + description: `Updated test connection at ${new Date().toISOString()}`, + host: `updated-host-${timestamp}.example.com`, + login: `updated_user_${timestamp}`, + port: 5433, + }; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.afterAll(async ({ browser }) => { + // Cleanup: Delete test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + // Delete the test connection + const deleteResponse = await page.request.delete( + `${baseUrl}/api/v2/connections/${testConnection.connection_id}`, + ); + + expect([204, 404]).toContain(deleteResponse.status()); + }); + + test("should create a new connection with all fields", async () => { + await connectionsPage.navigate(); + + // Click add button + await connectionsPage.createConnection(testConnection); + // Verify the connection was created + const exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + }); + + test("should display created connection in list with correct type", async () => { + await connectionsPage.navigate(); + + // Verify the connection is visible with correct details + await connectionsPage.verifyConnectionInList(testConnection.connection_id, testConnection.conn_type); + }); + + test("should edit an existing connection", async () => { + await connectionsPage.navigate(); + + // Verify connection exists before editing + let exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Edit the connection + await connectionsPage.editConnection(testConnection.connection_id, updatedConnection); + + // Verify the connection was updated + // await connectionsPage.navigate(); + exists = await connectionsPage.connectionExists(testConnection.connection_id); + expect(exists).toBeTruthy(); + }); + + test("should delete a connection", async () => { + // Create a temporary connection for deletion test + const tempConnection = { + conn_type: "postgres", + connection_id: `temp_conn_${timestamp}_delete`, + host: `temp-host-${timestamp}.example.com`, + login: "temp_user", + password: "temp_password", + }; + + await connectionsPage.navigate(); + await connectionsPage.createConnection(tempConnection); + let exists = await connectionsPage.connectionExists(tempConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Delete the connection + await connectionsPage.deleteConnection(tempConnection.connection_id); + exists = await connectionsPage.connectionExists(tempConnection.connection_id); + expect(exists).toBeFalsy(); + }); +}); + +test.describe("Connections Page - Pagination", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Create multiple test connections to ensure we have enough for pagination testing + const testConnections = Array.from({ length: 5 }, (_, i) => ({ + conn_type: "http", + connection_id: `pagination_test_${timestamp}_${i}`, + host: `pagination-host-${i}.example.com`, + login: `pagination_user_${i}`, + })); + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create multiple test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + // Connection may already exist + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup all test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should display pagination controls when applicable", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + expect(typeof hasPagination).toBe("boolean"); + }); + + test("should navigate to next page and verify data changes", async () => { + await connectionsPage.navigate(); + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (hasPagination) { Review Comment: If pagination isn't visible or next button isn't enabled, test silently passes without verifying anything. Can you check how pagination test are done for existing pages and implement same way ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,506 @@ +/*! + * 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, type Locator, type Page } from "@playwright/test"; +import { BasePage } from "tests/e2e/pages/BasePage"; + +type ConnectionDetails = { + conn_type: string; + connection_id: string; + description?: string; + extra?: string; + host?: string; + login?: string; + password?: string; + port?: number | string; + schema?: string; +}; + +export class ConnectionsPage extends BasePage { + // Page URLs + public static get connectionsListUrl(): string { + return "/connections"; + } + + public readonly addButton: Locator; + public readonly cancelDeleteButton: Locator; + public readonly confirmDeleteButton: 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 hostHeader: Locator; + public readonly hostInput: Locator; + public readonly loginInput: Locator; + + // Pagination elements + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + public readonly passwordInput: Locator; + + public readonly portInput: Locator; + public readonly rowsPerPageSelect: Locator; + public readonly saveButton: Locator; + + 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'); + + // Action buttons + this.addButton = page.getByRole("button", { name: "Add Connection" }); + this.testConnectionButton = page.locator('button:has-text("Test")'); + this.saveButton = page.getByRole("button", { name: /^save$/i }); + + // Form inputs (Chakra UI inputs) + this.connectionIdInput = page.locator('input[name="connection_id"]').first(); + this.connectionTypeSelect = page.getByRole("combobox").first(); + this.hostInput = page.locator('input[name="host"]').first(); + this.portInput = page.locator('input[name="port"]').first(); + this.loginInput = page.locator('input[name="login"]').first(); + this.passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + this.schemaInput = page.locator('input[name="schema"]').first(); + // Try multiple possible selectors + this.descriptionInput = page.locator('[name="description"]').first(); + + // Alerts + this.successAlert = page.locator('[data-scope="toast"][data-part="root"]'); + + // Delete confirmation dialog + this.confirmDeleteButton = page.locator('button:has-text("Delete")').first(); + this.cancelDeleteButton = page.locator('button:[aria-label*="Cancel"]').first(); + + // Pagination + this.paginationNextButton = page.locator('button[aria-label*="next page"]').first(); Review Comment: We can use the selectors below selectors here like we did in https://github.com/apache/airflow/pull/59400 ``` this.paginationNextButton = page.locator('[data-testid="next"]'); this.paginationPrevButton = page.locator('[data-testid="prev"]'); ``` ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,506 @@ +/*! + * 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, type Locator, type Page } from "@playwright/test"; +import { BasePage } from "tests/e2e/pages/BasePage"; + +type ConnectionDetails = { + conn_type: string; + connection_id: string; + description?: string; + extra?: string; + host?: string; + login?: string; + password?: string; + port?: number | string; + schema?: string; +}; + +export class ConnectionsPage extends BasePage { + // Page URLs + public static get connectionsListUrl(): string { + return "/connections"; + } + + public readonly addButton: Locator; + public readonly cancelDeleteButton: Locator; + public readonly confirmDeleteButton: 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 hostHeader: Locator; + public readonly hostInput: Locator; + public readonly loginInput: Locator; + + // Pagination elements + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + public readonly passwordInput: Locator; + + public readonly portInput: Locator; + public readonly rowsPerPageSelect: Locator; + public readonly saveButton: Locator; + + 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'); + + // Action buttons + this.addButton = page.getByRole("button", { name: "Add Connection" }); + this.testConnectionButton = page.locator('button:has-text("Test")'); + this.saveButton = page.getByRole("button", { name: /^save$/i }); + + // Form inputs (Chakra UI inputs) + this.connectionIdInput = page.locator('input[name="connection_id"]').first(); + this.connectionTypeSelect = page.getByRole("combobox").first(); + this.hostInput = page.locator('input[name="host"]').first(); + this.portInput = page.locator('input[name="port"]').first(); + this.loginInput = page.locator('input[name="login"]').first(); + this.passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + this.schemaInput = page.locator('input[name="schema"]').first(); + // Try multiple possible selectors + this.descriptionInput = page.locator('[name="description"]').first(); + + // Alerts + this.successAlert = page.locator('[data-scope="toast"][data-part="root"]'); + + // Delete confirmation dialog + this.confirmDeleteButton = page.locator('button:has-text("Delete")').first(); + this.cancelDeleteButton = page.locator('button:[aria-label*="Cancel"]').first(); + + // Pagination + this.paginationNextButton = page.locator('button[aria-label*="next page"]').first(); + this.paginationPrevButton = page.locator('button[aria-label*="previous page"]').first(); + this.rowsPerPageSelect = page.locator("select"); + + // Sorting and filtering + this.tableHeader = page.locator('[role="columnheader"]').first(); + this.connectionIdHeader = page.locator('[role="columnheader"]:has-text("Connection ID")').first(); + this.connectionTypeHeader = page.locator('[role="columnheader"]:has-text("Connection Type")').first(); + this.hostHeader = page.locator('[role="columnheader"]:has-text("Host")').first(); + this.searchInput = page.locator('input[placeholder*="Search"], input[placeholder*="search"]').first(); + } + + // Click the Add button to create a new connection + public async clickAddButton(): Promise<void> { + await expect(this.addButton).toBeVisible({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await this.page.waitForTimeout(1000); + } + + // Click edit button for a specific connection + public async clickEditButton(connectionId: string): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + const editButton = row.getByRole("button", { name: "Edit Connection" }); + + await expect(editButton).toBeVisible({ timeout: 2000 }); + await editButton.click(); + await this.page.waitForTimeout(500); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationNextButton).toBeVisible({ timeout: 5000 }); + await this.paginationNextButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Next page loaded"); Review Comment: lets remove log statement ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,506 @@ +/*! + * 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, type Locator, type Page } from "@playwright/test"; +import { BasePage } from "tests/e2e/pages/BasePage"; + +type ConnectionDetails = { + conn_type: string; + connection_id: string; + description?: string; + extra?: string; + host?: string; + login?: string; + password?: string; + port?: number | string; + schema?: string; +}; + +export class ConnectionsPage extends BasePage { + // Page URLs + public static get connectionsListUrl(): string { + return "/connections"; + } + + public readonly addButton: Locator; + public readonly cancelDeleteButton: Locator; + public readonly confirmDeleteButton: 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 hostHeader: Locator; + public readonly hostInput: Locator; + public readonly loginInput: Locator; + + // Pagination elements + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + public readonly passwordInput: Locator; + + public readonly portInput: Locator; + public readonly rowsPerPageSelect: Locator; + public readonly saveButton: Locator; + + 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'); + + // Action buttons + this.addButton = page.getByRole("button", { name: "Add Connection" }); + this.testConnectionButton = page.locator('button:has-text("Test")'); + this.saveButton = page.getByRole("button", { name: /^save$/i }); + + // Form inputs (Chakra UI inputs) + this.connectionIdInput = page.locator('input[name="connection_id"]').first(); + this.connectionTypeSelect = page.getByRole("combobox").first(); + this.hostInput = page.locator('input[name="host"]').first(); + this.portInput = page.locator('input[name="port"]').first(); + this.loginInput = page.locator('input[name="login"]').first(); + this.passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + this.schemaInput = page.locator('input[name="schema"]').first(); + // Try multiple possible selectors + this.descriptionInput = page.locator('[name="description"]').first(); + + // Alerts + this.successAlert = page.locator('[data-scope="toast"][data-part="root"]'); + + // Delete confirmation dialog + this.confirmDeleteButton = page.locator('button:has-text("Delete")').first(); + this.cancelDeleteButton = page.locator('button:[aria-label*="Cancel"]').first(); + + // Pagination + this.paginationNextButton = page.locator('button[aria-label*="next page"]').first(); + this.paginationPrevButton = page.locator('button[aria-label*="previous page"]').first(); + this.rowsPerPageSelect = page.locator("select"); + + // Sorting and filtering + this.tableHeader = page.locator('[role="columnheader"]').first(); + this.connectionIdHeader = page.locator('[role="columnheader"]:has-text("Connection ID")').first(); + this.connectionTypeHeader = page.locator('[role="columnheader"]:has-text("Connection Type")').first(); + this.hostHeader = page.locator('[role="columnheader"]:has-text("Host")').first(); + this.searchInput = page.locator('input[placeholder*="Search"], input[placeholder*="search"]').first(); + } + + // Click the Add button to create a new connection + public async clickAddButton(): Promise<void> { + await expect(this.addButton).toBeVisible({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await this.page.waitForTimeout(1000); + } + + // Click edit button for a specific connection + public async clickEditButton(connectionId: string): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + const editButton = row.getByRole("button", { name: "Edit Connection" }); + + await expect(editButton).toBeVisible({ timeout: 2000 }); + await editButton.click(); + await this.page.waitForTimeout(500); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationNextButton).toBeVisible({ timeout: 5000 }); + await this.paginationNextButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Next page loaded"); + } + + // Click next page button for pagination + public async clickPrevPage(): Promise<void> { + const isEnabled = await this.paginationPrevButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationPrevButton).toBeVisible({ timeout: 5000 }); + await this.paginationPrevButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Previous page loaded"); Review Comment: Remove all console.log statements - tests should use assertions, not logs. ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,506 @@ +/*! + * 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, type Locator, type Page } from "@playwright/test"; +import { BasePage } from "tests/e2e/pages/BasePage"; + +type ConnectionDetails = { + conn_type: string; + connection_id: string; + description?: string; + extra?: string; + host?: string; + login?: string; + password?: string; + port?: number | string; + schema?: string; +}; + +export class ConnectionsPage extends BasePage { + // Page URLs + public static get connectionsListUrl(): string { + return "/connections"; + } + + public readonly addButton: Locator; + public readonly cancelDeleteButton: Locator; + public readonly confirmDeleteButton: 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 hostHeader: Locator; + public readonly hostInput: Locator; + public readonly loginInput: Locator; + + // Pagination elements + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + public readonly passwordInput: Locator; + + public readonly portInput: Locator; + public readonly rowsPerPageSelect: Locator; + public readonly saveButton: Locator; + + 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'); + + // Action buttons + this.addButton = page.getByRole("button", { name: "Add Connection" }); + this.testConnectionButton = page.locator('button:has-text("Test")'); + this.saveButton = page.getByRole("button", { name: /^save$/i }); + + // Form inputs (Chakra UI inputs) + this.connectionIdInput = page.locator('input[name="connection_id"]').first(); + this.connectionTypeSelect = page.getByRole("combobox").first(); + this.hostInput = page.locator('input[name="host"]').first(); + this.portInput = page.locator('input[name="port"]').first(); + this.loginInput = page.locator('input[name="login"]').first(); + this.passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + this.schemaInput = page.locator('input[name="schema"]').first(); + // Try multiple possible selectors + this.descriptionInput = page.locator('[name="description"]').first(); + + // Alerts + this.successAlert = page.locator('[data-scope="toast"][data-part="root"]'); + + // Delete confirmation dialog + this.confirmDeleteButton = page.locator('button:has-text("Delete")').first(); + this.cancelDeleteButton = page.locator('button:[aria-label*="Cancel"]').first(); + + // Pagination + this.paginationNextButton = page.locator('button[aria-label*="next page"]').first(); + this.paginationPrevButton = page.locator('button[aria-label*="previous page"]').first(); + this.rowsPerPageSelect = page.locator("select"); + + // Sorting and filtering + this.tableHeader = page.locator('[role="columnheader"]').first(); + this.connectionIdHeader = page.locator('[role="columnheader"]:has-text("Connection ID")').first(); + this.connectionTypeHeader = page.locator('[role="columnheader"]:has-text("Connection Type")').first(); + this.hostHeader = page.locator('[role="columnheader"]:has-text("Host")').first(); + this.searchInput = page.locator('input[placeholder*="Search"], input[placeholder*="search"]').first(); + } + + // Click the Add button to create a new connection + public async clickAddButton(): Promise<void> { + await expect(this.addButton).toBeVisible({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await this.page.waitForTimeout(1000); + } + + // Click edit button for a specific connection + public async clickEditButton(connectionId: string): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + const editButton = row.getByRole("button", { name: "Edit Connection" }); + + await expect(editButton).toBeVisible({ timeout: 2000 }); + await editButton.click(); + await this.page.waitForTimeout(500); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationNextButton).toBeVisible({ timeout: 5000 }); + await this.paginationNextButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Next page loaded"); + } + + // Click next page button for pagination + public async clickPrevPage(): Promise<void> { + const isEnabled = await this.paginationPrevButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationPrevButton).toBeVisible({ timeout: 5000 }); + await this.paginationPrevButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Previous page loaded"); + } + + // Check if a connection exists in the current view + public async connectionExists(connectionId: string): Promise<boolean> { + const emptyState = await this.page + .locator("text=No connection found!") + .isVisible({ timeout: 1000 }) + .catch(() => false); + + if (emptyState) { + console.log("Empty state detected - no connections exist"); + + return false; + } + const row = await this.findConnectionRow(connectionId); + const visible = row !== null; + + return visible; + } + + // Create a new connection with full workflow + public async createConnection(details: ConnectionDetails): Promise<void> { + await this.clickAddButton(); + await this.fillConnectionForm(details); + await this.saveConnection(details.connection_id); + console.log(`✓ Connection ${details.connection_id} created`); + } + + // Delete a connection by connection ID + public async deleteConnection(connectionId: string): Promise<void> { + // await this.navigate(); + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + // Find delete button in the row + await this.page.evaluate(() => { + const backdrops = document.querySelectorAll<HTMLElement>('[data-scope="dialog"][data-part="backdrop"]'); + + backdrops.forEach((backdrop) => { + const { state } = backdrop.dataset; + + if (state === "closed") { + console.log("Removing stuck closed backdrop"); + backdrop.remove(); + } + }); + }); + const deleteButton = row.getByRole("button", { name: "Delete Connection" }); + + await expect(deleteButton).toBeVisible({ timeout: 1000 }); + await deleteButton.click(); + + console.log("Waiting for delete confirmation dialog..."); + + // Wait for confirmation dialog + await this.page.waitForTimeout(500); + + // Handle delete confirmation - try multiple button texts + await expect(this.confirmDeleteButton).toBeVisible({ timeout: 2000 }); + await this.confirmDeleteButton.click(); + + // Wait a moment for backend to process + await this.page.waitForTimeout(1000); + + console.log(`✓ Connection ${connectionId} deleted`); + await this.searchInput.clear(); + } + + // Edit a connection by connection ID + public async editConnection(connectionId: string, updates: Partial<ConnectionDetails>): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + await this.clickEditButton(connectionId); + + // Wait for form to load + await this.page.waitForTimeout(1000); + + // Fill the fields that need updating + await this.fillConnectionForm(updates); + await this.saveConnection(connectionId); + console.log(`✓ Connection ${connectionId} edited`); Review Comment: lets remove log ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,506 @@ +/*! + * 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, type Locator, type Page } from "@playwright/test"; +import { BasePage } from "tests/e2e/pages/BasePage"; + +type ConnectionDetails = { + conn_type: string; + connection_id: string; + description?: string; + extra?: string; + host?: string; + login?: string; + password?: string; + port?: number | string; + schema?: string; +}; + +export class ConnectionsPage extends BasePage { + // Page URLs + public static get connectionsListUrl(): string { + return "/connections"; + } + + public readonly addButton: Locator; + public readonly cancelDeleteButton: Locator; + public readonly confirmDeleteButton: 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 hostHeader: Locator; + public readonly hostInput: Locator; + public readonly loginInput: Locator; + + // Pagination elements + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + public readonly passwordInput: Locator; + + public readonly portInput: Locator; + public readonly rowsPerPageSelect: Locator; + public readonly saveButton: Locator; + + 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'); + + // Action buttons + this.addButton = page.getByRole("button", { name: "Add Connection" }); + this.testConnectionButton = page.locator('button:has-text("Test")'); + this.saveButton = page.getByRole("button", { name: /^save$/i }); + + // Form inputs (Chakra UI inputs) + this.connectionIdInput = page.locator('input[name="connection_id"]').first(); + this.connectionTypeSelect = page.getByRole("combobox").first(); + this.hostInput = page.locator('input[name="host"]').first(); + this.portInput = page.locator('input[name="port"]').first(); + this.loginInput = page.locator('input[name="login"]').first(); + this.passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + this.schemaInput = page.locator('input[name="schema"]').first(); + // Try multiple possible selectors + this.descriptionInput = page.locator('[name="description"]').first(); + + // Alerts + this.successAlert = page.locator('[data-scope="toast"][data-part="root"]'); + + // Delete confirmation dialog + this.confirmDeleteButton = page.locator('button:has-text("Delete")').first(); + this.cancelDeleteButton = page.locator('button:[aria-label*="Cancel"]').first(); + + // Pagination + this.paginationNextButton = page.locator('button[aria-label*="next page"]').first(); + this.paginationPrevButton = page.locator('button[aria-label*="previous page"]').first(); + this.rowsPerPageSelect = page.locator("select"); + + // Sorting and filtering + this.tableHeader = page.locator('[role="columnheader"]').first(); + this.connectionIdHeader = page.locator('[role="columnheader"]:has-text("Connection ID")').first(); + this.connectionTypeHeader = page.locator('[role="columnheader"]:has-text("Connection Type")').first(); + this.hostHeader = page.locator('[role="columnheader"]:has-text("Host")').first(); + this.searchInput = page.locator('input[placeholder*="Search"], input[placeholder*="search"]').first(); + } + + // Click the Add button to create a new connection + public async clickAddButton(): Promise<void> { + await expect(this.addButton).toBeVisible({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await this.page.waitForTimeout(1000); + } + + // Click edit button for a specific connection + public async clickEditButton(connectionId: string): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + const editButton = row.getByRole("button", { name: "Edit Connection" }); + + await expect(editButton).toBeVisible({ timeout: 2000 }); + await editButton.click(); + await this.page.waitForTimeout(500); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationNextButton).toBeVisible({ timeout: 5000 }); + await this.paginationNextButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Next page loaded"); + } + + // Click next page button for pagination + public async clickPrevPage(): Promise<void> { + const isEnabled = await this.paginationPrevButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationPrevButton).toBeVisible({ timeout: 5000 }); + await this.paginationPrevButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Previous page loaded"); + } + + // Check if a connection exists in the current view + public async connectionExists(connectionId: string): Promise<boolean> { + const emptyState = await this.page + .locator("text=No connection found!") + .isVisible({ timeout: 1000 }) + .catch(() => false); + + if (emptyState) { + console.log("Empty state detected - no connections exist"); + + return false; + } + const row = await this.findConnectionRow(connectionId); + const visible = row !== null; + + return visible; + } + + // Create a new connection with full workflow + public async createConnection(details: ConnectionDetails): Promise<void> { + await this.clickAddButton(); + await this.fillConnectionForm(details); + await this.saveConnection(details.connection_id); + console.log(`✓ Connection ${details.connection_id} created`); + } + + // Delete a connection by connection ID + public async deleteConnection(connectionId: string): Promise<void> { + // await this.navigate(); + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + // Find delete button in the row + await this.page.evaluate(() => { + const backdrops = document.querySelectorAll<HTMLElement>('[data-scope="dialog"][data-part="backdrop"]'); + + backdrops.forEach((backdrop) => { + const { state } = backdrop.dataset; + + if (state === "closed") { + console.log("Removing stuck closed backdrop"); + backdrop.remove(); + } + }); + }); + const deleteButton = row.getByRole("button", { name: "Delete Connection" }); + + await expect(deleteButton).toBeVisible({ timeout: 1000 }); + await deleteButton.click(); + + console.log("Waiting for delete confirmation dialog..."); + + // Wait for confirmation dialog + await this.page.waitForTimeout(500); + + // Handle delete confirmation - try multiple button texts + await expect(this.confirmDeleteButton).toBeVisible({ timeout: 2000 }); + await this.confirmDeleteButton.click(); + + // Wait a moment for backend to process + await this.page.waitForTimeout(1000); + + console.log(`✓ Connection ${connectionId} deleted`); + await this.searchInput.clear(); + } + + // Edit a connection by connection ID + public async editConnection(connectionId: string, updates: Partial<ConnectionDetails>): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + await this.clickEditButton(connectionId); + + // Wait for form to load + await this.page.waitForTimeout(1000); Review Comment: Let's not use wait. Use explicit element waits or expect.poll() for state changes. ########## airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts: ########## @@ -0,0 +1,563 @@ +/*! + * 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, AUTH_FILE } from "playwright.config"; +import { ConnectionsPage } from "tests/e2e/pages/ConnectionsPage"; + +test.describe("Connections Page - List and Display", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.post(`${baseUrl}/api/v2/connections`, { + data: { + conn_type: "http", + connection_id: "list_seed_conn", + host: "seed.example.com", + }, + }); + }); + + test.afterAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.delete(`${baseUrl}/api/v2/connections/list_seed_conn`); + }); + + test("should display connections list page", async () => { + await connectionsPage.navigate(); + + // Verify the page is loaded + expect(connectionsPage.page.url()).toContain("/connections"); + + // Verify table or list is visible + expect(await connectionsPage.connectionsTable.isVisible()).toBeTruthy(); + }); + + test("should display connections with correct columns", async () => { + await connectionsPage.navigate(); + + // Check that we have at least one row + const count = await connectionsPage.getConnectionCount(); + + expect(count).toBeGreaterThanOrEqual(0); + + if (count > 0) { + // Verify connections are listed with expected information + const connectionIds = await connectionsPage.getConnectionIds(); + + expect(connectionIds.length).toBeGreaterThan(0); + } + }); + + test("should have Add button visible", async () => { + await connectionsPage.navigate(); + expect(await connectionsPage.addButton.isVisible()).toBeTruthy(); + }); +}); + +test.describe("Connections Page - CRUD Operations", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Test connection details - using dynamic data + const testConnection = { + conn_type: "postgres", // Adjust based on available connection types in your Airflow instance + connection_id: `atest_conn_${timestamp}`, + description: `Test connection created at ${new Date().toISOString()}`, + extra: JSON.stringify({ + options: "-c statement_timeout=5000", + sslmode: "require", + }), + host: `test-host-${timestamp}.example.com`, + login: `test_user_${timestamp}`, + password: `test_password_${timestamp}`, + port: 5432, + schema: "test_db", + }; + + const updatedConnection = { + description: `Updated test connection at ${new Date().toISOString()}`, + host: `updated-host-${timestamp}.example.com`, + login: `updated_user_${timestamp}`, + port: 5433, + }; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.afterAll(async ({ browser }) => { + // Cleanup: Delete test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + // Delete the test connection + const deleteResponse = await page.request.delete( + `${baseUrl}/api/v2/connections/${testConnection.connection_id}`, + ); + + expect([204, 404]).toContain(deleteResponse.status()); + }); + + test("should create a new connection with all fields", async () => { + await connectionsPage.navigate(); + + // Click add button + await connectionsPage.createConnection(testConnection); + // Verify the connection was created + const exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + }); + + test("should display created connection in list with correct type", async () => { + await connectionsPage.navigate(); + + // Verify the connection is visible with correct details + await connectionsPage.verifyConnectionInList(testConnection.connection_id, testConnection.conn_type); + }); + + test("should edit an existing connection", async () => { + await connectionsPage.navigate(); + + // Verify connection exists before editing + let exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Edit the connection + await connectionsPage.editConnection(testConnection.connection_id, updatedConnection); + + // Verify the connection was updated + // await connectionsPage.navigate(); + exists = await connectionsPage.connectionExists(testConnection.connection_id); + expect(exists).toBeTruthy(); + }); + + test("should delete a connection", async () => { + // Create a temporary connection for deletion test + const tempConnection = { + conn_type: "postgres", + connection_id: `temp_conn_${timestamp}_delete`, + host: `temp-host-${timestamp}.example.com`, + login: "temp_user", + password: "temp_password", + }; + + await connectionsPage.navigate(); + await connectionsPage.createConnection(tempConnection); + let exists = await connectionsPage.connectionExists(tempConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Delete the connection + await connectionsPage.deleteConnection(tempConnection.connection_id); + exists = await connectionsPage.connectionExists(tempConnection.connection_id); + expect(exists).toBeFalsy(); + }); +}); + +test.describe("Connections Page - Pagination", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Create multiple test connections to ensure we have enough for pagination testing + const testConnections = Array.from({ length: 5 }, (_, i) => ({ + conn_type: "http", + connection_id: `pagination_test_${timestamp}_${i}`, + host: `pagination-host-${i}.example.com`, + login: `pagination_user_${i}`, + })); + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create multiple test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + // Connection may already exist + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup all test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should display pagination controls when applicable", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + expect(typeof hasPagination).toBe("boolean"); + }); + + test("should navigate to next page and verify data changes", async () => { + await connectionsPage.navigate(); + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (hasPagination) { + const initialIds = await connectionsPage.getConnectionIds(); + + expect(initialIds.length).toBeGreaterThan(0); + + // Check if next button is enabled + const nextButtonEnabled = await connectionsPage.paginationNextButton + .isEnabled({ timeout: 2000 }) + .catch(() => false); + + if (nextButtonEnabled) { + await connectionsPage.clickNextPage(); + const newIds = await connectionsPage.getConnectionIds(); + + // Verify we have connections on the new page + expect(newIds.length).toBeGreaterThan(0); + } + } + }); + + test("should navigate to previous page and return to original", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (hasPagination) { + const nextButtonEnabled = await connectionsPage.paginationNextButton + .isEnabled({ timeout: 2000 }) + .catch(() => false); + + if (nextButtonEnabled) { + await connectionsPage.clickNextPage(); + + // Check if previous button is enabled + const prevButtonEnabled = await connectionsPage.paginationPrevButton + .isEnabled({ timeout: 2000 }) + .catch(() => false); + + if (prevButtonEnabled) { + // Go back to first page + await connectionsPage.clickPrevPage(); + const returnedIds = await connectionsPage.getConnectionIds(); + + expect(returnedIds.length).toBeGreaterThan(0); + } + } + } + }); +}); + +test.describe("Connections Page - Sorting", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Create test connections with distinct names for sorting + const sortTestConnections = [ + { + conn_type: "http", + connection_id: `z_sort_conn_${timestamp}`, + host: "z-host.example.com", + login: "z_user", + }, + { + conn_type: "postgres", + connection_id: `a_sort_conn_${timestamp}`, + host: "a-host.example.com", + login: "a_user", + }, + { + conn_type: "mysql", + connection_id: `m_sort_conn_${timestamp}`, + host: "m-host.example.com", + login: "m_user", + }, + ]; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of sortTestConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of sortTestConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should sort by Connection Type when clicking header", async () => { + await connectionsPage.navigate(); + + // Click to sort (first click should be ascending) + await connectionsPage.sortByHeader("Connection ID"); + const idsAfter = await connectionsPage.getConnectionIds(); + + // Verify it's actually sorted (case-insensitive) + const sortedIds = [...idsAfter].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + expect(idsAfter).toEqual(sortedIds); + }); + + test("should toggle sort order when clicking header twice", async () => { + await connectionsPage.navigate(); + + // First click + await connectionsPage.sortByHeader("Connection ID"); + const idsAsc = await connectionsPage.getConnectionIds(); + + expect(idsAsc.length).toBeGreaterThan(0); + + // Verify it's sorted ascending + let isAscending = true; + + for (let i = 0; i < idsAsc.length - 1; i++) { + const current = idsAsc[i]; + const next = idsAsc[i + 1]; + + // TypeScript safety check + if (current === undefined || current === "" || next === undefined || next === "") continue; + + if (current.toLowerCase() > next.toLowerCase()) { + isAscending = false; + break; + } + } + expect(isAscending).toBe(true); + + // Second click + await connectionsPage.sortByHeader("Connection ID"); + const idsDesc = await connectionsPage.getConnectionIds(); + + expect(idsDesc.length).toBeGreaterThan(0); + + // Verify it's sorted descending + let isDescending = true; + + for (let i = 0; i < idsDesc.length - 1; i++) { + const current = idsDesc[i]; + const next = idsDesc[i + 1]; + + // TypeScript safety check + if (current === undefined || current === "" || next === undefined || next === "") continue; + if (current.toLowerCase() < next.toLowerCase()) { + isDescending = false; + break; + } + } + expect(isDescending).toBe(true); + }); + + test("should keep each page sorted after navigating", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (!hasPagination) { + test.skip(); + + return; + } + + // Sort first page + await connectionsPage.sortByHeader("Connection ID"); + const firstPageIds = await connectionsPage.getConnectionIds(); + + if (firstPageIds.length === 0) { + test.skip(); + + return; + } + + const nextButtonEnabled = await connectionsPage.paginationNextButton.isEnabled().catch(() => false); + + if (!nextButtonEnabled) { + test.skip(); + + return; + } + + // Navigate to next page + await connectionsPage.clickNextPage(); + const secondPageIds = await connectionsPage.getConnectionIds(); + + expect(secondPageIds.length).toBeGreaterThan(0); + + // Verify second page is ALSO sorted (this is what matters!) + const secondPageSorted = secondPageIds.every((id, i) => { + if (i === secondPageIds.length - 1) return true; + const nextId = secondPageIds[i + 1]; + + return nextId !== undefined && id.toLowerCase() <= nextId.toLowerCase(); + }); + + expect(secondPageSorted).toBe(true); + }); +}); + +test.describe("Connections Page - Search and Filter", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + const searchTestConnections = [ + { + conn_type: "postgres", + connection_id: `search_production_${timestamp}`, + host: "prod-db.example.com", + login: "prod_user", + }, + { + conn_type: "mysql", + connection_id: `search_staging_${timestamp}`, + host: "staging-db.example.com", + login: "staging_user", + }, + { + conn_type: "http", + connection_id: `search_development_${timestamp}`, + host: "dev-api.example.com", + login: "dev_user", + }, + ]; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of searchTestConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of searchTestConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should filter connections by search term", async () => { Review Comment: can you please check how existing filtering test are implementated in main ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,506 @@ +/*! + * 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, type Locator, type Page } from "@playwright/test"; +import { BasePage } from "tests/e2e/pages/BasePage"; + +type ConnectionDetails = { + conn_type: string; + connection_id: string; + description?: string; + extra?: string; + host?: string; + login?: string; + password?: string; + port?: number | string; + schema?: string; +}; + +export class ConnectionsPage extends BasePage { + // Page URLs + public static get connectionsListUrl(): string { + return "/connections"; + } + + public readonly addButton: Locator; + public readonly cancelDeleteButton: Locator; + public readonly confirmDeleteButton: 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 hostHeader: Locator; + public readonly hostInput: Locator; + public readonly loginInput: Locator; + + // Pagination elements + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + public readonly passwordInput: Locator; + + public readonly portInput: Locator; + public readonly rowsPerPageSelect: Locator; + public readonly saveButton: Locator; + + 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'); + + // Action buttons + this.addButton = page.getByRole("button", { name: "Add Connection" }); + this.testConnectionButton = page.locator('button:has-text("Test")'); + this.saveButton = page.getByRole("button", { name: /^save$/i }); + + // Form inputs (Chakra UI inputs) + this.connectionIdInput = page.locator('input[name="connection_id"]').first(); + this.connectionTypeSelect = page.getByRole("combobox").first(); + this.hostInput = page.locator('input[name="host"]').first(); + this.portInput = page.locator('input[name="port"]').first(); + this.loginInput = page.locator('input[name="login"]').first(); + this.passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + this.schemaInput = page.locator('input[name="schema"]').first(); + // Try multiple possible selectors + this.descriptionInput = page.locator('[name="description"]').first(); + + // Alerts + this.successAlert = page.locator('[data-scope="toast"][data-part="root"]'); + + // Delete confirmation dialog + this.confirmDeleteButton = page.locator('button:has-text("Delete")').first(); + this.cancelDeleteButton = page.locator('button:[aria-label*="Cancel"]').first(); + + // Pagination + this.paginationNextButton = page.locator('button[aria-label*="next page"]').first(); + this.paginationPrevButton = page.locator('button[aria-label*="previous page"]').first(); + this.rowsPerPageSelect = page.locator("select"); + + // Sorting and filtering + this.tableHeader = page.locator('[role="columnheader"]').first(); + this.connectionIdHeader = page.locator('[role="columnheader"]:has-text("Connection ID")').first(); + this.connectionTypeHeader = page.locator('[role="columnheader"]:has-text("Connection Type")').first(); + this.hostHeader = page.locator('[role="columnheader"]:has-text("Host")').first(); + this.searchInput = page.locator('input[placeholder*="Search"], input[placeholder*="search"]').first(); + } + + // Click the Add button to create a new connection + public async clickAddButton(): Promise<void> { + await expect(this.addButton).toBeVisible({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await this.page.waitForTimeout(1000); + } + + // Click edit button for a specific connection + public async clickEditButton(connectionId: string): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + const editButton = row.getByRole("button", { name: "Edit Connection" }); + + await expect(editButton).toBeVisible({ timeout: 2000 }); + await editButton.click(); + await this.page.waitForTimeout(500); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationNextButton).toBeVisible({ timeout: 5000 }); + await this.paginationNextButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Next page loaded"); + } + + // Click next page button for pagination + public async clickPrevPage(): Promise<void> { + const isEnabled = await this.paginationPrevButton.isEnabled({ timeout: 2000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + await expect(this.paginationPrevButton).toBeVisible({ timeout: 5000 }); + await this.paginationPrevButton.click(); + + // await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + await this.waitForConnectionsListLoad(); + console.log("✓ Previous page loaded"); + } + + // Check if a connection exists in the current view + public async connectionExists(connectionId: string): Promise<boolean> { + const emptyState = await this.page + .locator("text=No connection found!") + .isVisible({ timeout: 1000 }) + .catch(() => false); + + if (emptyState) { + console.log("Empty state detected - no connections exist"); + + return false; + } + const row = await this.findConnectionRow(connectionId); + const visible = row !== null; + + return visible; + } + + // Create a new connection with full workflow + public async createConnection(details: ConnectionDetails): Promise<void> { + await this.clickAddButton(); + await this.fillConnectionForm(details); + await this.saveConnection(details.connection_id); + console.log(`✓ Connection ${details.connection_id} created`); + } + + // Delete a connection by connection ID + public async deleteConnection(connectionId: string): Promise<void> { + // await this.navigate(); + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + // Find delete button in the row + await this.page.evaluate(() => { + const backdrops = document.querySelectorAll<HTMLElement>('[data-scope="dialog"][data-part="backdrop"]'); + + backdrops.forEach((backdrop) => { + const { state } = backdrop.dataset; + + if (state === "closed") { + console.log("Removing stuck closed backdrop"); + backdrop.remove(); + } + }); + }); + const deleteButton = row.getByRole("button", { name: "Delete Connection" }); + + await expect(deleteButton).toBeVisible({ timeout: 1000 }); + await deleteButton.click(); + + console.log("Waiting for delete confirmation dialog..."); + + // Wait for confirmation dialog + await this.page.waitForTimeout(500); + + // Handle delete confirmation - try multiple button texts + await expect(this.confirmDeleteButton).toBeVisible({ timeout: 2000 }); + await this.confirmDeleteButton.click(); + + // Wait a moment for backend to process + await this.page.waitForTimeout(1000); + + console.log(`✓ Connection ${connectionId} deleted`); + await this.searchInput.clear(); + } + + // Edit a connection by connection ID + public async editConnection(connectionId: string, updates: Partial<ConnectionDetails>): Promise<void> { + const row = await this.findConnectionRow(connectionId); + + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + await this.clickEditButton(connectionId); + + // Wait for form to load + await this.page.waitForTimeout(1000); + + // Fill the fields that need updating + await this.fillConnectionForm(updates); + await this.saveConnection(connectionId); + console.log(`✓ Connection ${connectionId} edited`); + await this.searchInput.clear(); + } + + // Fill connection form with details + public async fillConnectionForm(details: Partial<ConnectionDetails>): Promise<void> { + if (details.connection_id !== undefined && details.connection_id !== "") { + await this.connectionIdInput.fill(details.connection_id); + } + + if (details.conn_type !== undefined && details.conn_type !== "") { + // Click the select field to open the dropdown + const selectCombobox = this.page.getByRole("combobox").first(); + + await selectCombobox.click({ timeout: 3000 }).catch(() => { + // Dropdown might already be open + }); + + // Wait for options to appear and click the matching option + const option = this.page.getByRole("option", { name: new RegExp(details.conn_type, "i") }).first(); + + await option.click({ timeout: 2000 }).catch(() => { + // If option click fails, try typing in the input + if (details.conn_type !== undefined && details.conn_type !== "") { + void this.page.keyboard.type(details.conn_type); + } + }); + } + + if (details.host !== undefined && details.host !== "") { + await this.hostInput.fill(details.host); + } + + if (details.port !== undefined && details.port !== "") { + await this.portInput.fill(String(details.port)); + } + + if (details.login !== undefined && details.login !== "") { + await this.loginInput.fill(details.login); + } + + if (details.password !== undefined && details.password !== "") { + await this.passwordInput.fill(details.password); + } + + if (details.description !== undefined && details.description !== "") { + await this.descriptionInput.fill(details.description); + } + + if (details.schema !== undefined && details.schema !== "") { + await this.schemaInput.fill(details.schema); + } + + if (details.extra !== undefined && details.extra !== "") { + const extraAccordion = this.page.locator('button:has-text("Extra Fields JSON")').first(); + const accordionVisible = await extraAccordion.isVisible({ timeout: 3000 }).catch(() => false); + + if (accordionVisible) { + await extraAccordion.click(); + await this.page.waitForTimeout(500); + const extraEditor = this.page.locator('.cm-content[contenteditable="true"]:visible').first(); + + await extraEditor.waitFor({ state: "visible", timeout: 3000 }); + await extraEditor.clear(); + await extraEditor.fill(details.extra); + await extraEditor.blur(); + } else { + console.warn( + "Extra Fields JSON accordion not found - field may not be available for this connection type", + ); + } + } + } + + // Get the current sort order of a column + public async getColumnSortOrder(headerName: string): Promise<"asc" | "desc" | null> { + const header = this.page.locator(`th:has-text("${headerName}")`).first(); + const sortIcon = header.locator('[aria-label*="sort"], [data-testid*="sort"]'); + + if (await sortIcon.isVisible()) { + const ariaSort = await header.getAttribute("aria-sort"); + + if (ariaSort === "ascending") return "asc"; + if (ariaSort === "descending") return "desc"; + } + + return null; + } + + // Get connection count from current page + public async getConnectionCount(): Promise<number> { + const ids = await this.getConnectionIds(); + + return ids.length; + } + + // Get all connection IDs from the current page + public async getConnectionIds(): Promise<Array<string>> { + // Try DataTable structure first (role="row"), then fall back to standard HTML table + let rows = this.page.locator('[role="row"]'); + let rowCount = await rows.count(); + + // If no rows found with role="row", try standard HTML table + if (rowCount === 0) { + rows = this.page.locator("tbody tr"); + rowCount = await rows.count(); + } + + const connectionIds: Array<string> = []; + + // Process all rows + for (let i = 0; i < rowCount; i++) { + try { + const row = rows.nth(i); + const cells = row.locator("td"); + const cellCount = await cells.count(); + + if (cellCount > 1) { + // Connection ID is typically in the second cell (after checkbox) + const idCell = cells.nth(1); + const text = await idCell.textContent({ timeout: 3000 }); + + if (text !== null && text.trim() !== "") { + connectionIds.push(text.trim()); + } + } + } catch { + // Skip rows that can't be read + continue; + } + } + + return connectionIds; + } + + // Check if pagination is visible + public async isPaginationVisible(): Promise<boolean> { + try { + const nextVisible = await this.paginationNextButton.isVisible({ timeout: 2000 }); + const prevVisible = await this.paginationPrevButton.isVisible({ timeout: 2000 }); + + return nextVisible || prevVisible; + } catch { + return false; + } + } + + // Navigate to Connections list page + public async navigate(): Promise<void> { + await this.navigateTo(ConnectionsPage.connectionsListUrl); + await this.waitForConnectionsListLoad(); + } + + // Save the connection form + public async saveConnection(connectionId: string): Promise<void> { + await this.saveButton.scrollIntoViewIfNeeded(); + await expect(this.saveButton).toBeVisible({ timeout: 2000 }); + console.log(`Saving connection ${connectionId}...`); + await this.saveButton.click(); + + // Wait for either redirect OR success message + await Promise.race([ + this.page.waitForURL("**/connections", { timeout: 10_000 }), + this.successAlert.waitFor({ state: "visible", timeout: 10_000 }), + ]); + + await this.page.waitForTimeout(1000); + } + + // Search for connections using the search input + public async searchConnections(searchTerm: string): Promise<void> { + if (await this.searchInput.isVisible()) { + await this.searchInput.fill(searchTerm); + // Wait for search results + await this.page.waitForTimeout(500); Review Comment: Lets not use hardcode wait , use explicit waits or expect.poll ########## airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts: ########## @@ -0,0 +1,563 @@ +/*! + * 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, AUTH_FILE } from "playwright.config"; +import { ConnectionsPage } from "tests/e2e/pages/ConnectionsPage"; + +test.describe("Connections Page - List and Display", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.post(`${baseUrl}/api/v2/connections`, { + data: { + conn_type: "http", + connection_id: "list_seed_conn", + host: "seed.example.com", + }, + }); + }); + + test.afterAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.request.delete(`${baseUrl}/api/v2/connections/list_seed_conn`); + }); + + test("should display connections list page", async () => { + await connectionsPage.navigate(); + + // Verify the page is loaded + expect(connectionsPage.page.url()).toContain("/connections"); + + // Verify table or list is visible + expect(await connectionsPage.connectionsTable.isVisible()).toBeTruthy(); + }); + + test("should display connections with correct columns", async () => { + await connectionsPage.navigate(); + + // Check that we have at least one row + const count = await connectionsPage.getConnectionCount(); + + expect(count).toBeGreaterThanOrEqual(0); + + if (count > 0) { + // Verify connections are listed with expected information + const connectionIds = await connectionsPage.getConnectionIds(); + + expect(connectionIds.length).toBeGreaterThan(0); + } + }); + + test("should have Add button visible", async () => { + await connectionsPage.navigate(); + expect(await connectionsPage.addButton.isVisible()).toBeTruthy(); + }); +}); + +test.describe("Connections Page - CRUD Operations", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Test connection details - using dynamic data + const testConnection = { + conn_type: "postgres", // Adjust based on available connection types in your Airflow instance + connection_id: `atest_conn_${timestamp}`, + description: `Test connection created at ${new Date().toISOString()}`, + extra: JSON.stringify({ + options: "-c statement_timeout=5000", + sslmode: "require", + }), + host: `test-host-${timestamp}.example.com`, + login: `test_user_${timestamp}`, + password: `test_password_${timestamp}`, + port: 5432, + schema: "test_db", + }; + + const updatedConnection = { + description: `Updated test connection at ${new Date().toISOString()}`, + host: `updated-host-${timestamp}.example.com`, + login: `updated_user_${timestamp}`, + port: 5433, + }; + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.afterAll(async ({ browser }) => { + // Cleanup: Delete test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + // Delete the test connection + const deleteResponse = await page.request.delete( + `${baseUrl}/api/v2/connections/${testConnection.connection_id}`, + ); + + expect([204, 404]).toContain(deleteResponse.status()); + }); + + test("should create a new connection with all fields", async () => { + await connectionsPage.navigate(); + + // Click add button + await connectionsPage.createConnection(testConnection); + // Verify the connection was created + const exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + }); + + test("should display created connection in list with correct type", async () => { + await connectionsPage.navigate(); + + // Verify the connection is visible with correct details + await connectionsPage.verifyConnectionInList(testConnection.connection_id, testConnection.conn_type); + }); + + test("should edit an existing connection", async () => { + await connectionsPage.navigate(); + + // Verify connection exists before editing + let exists = await connectionsPage.connectionExists(testConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Edit the connection + await connectionsPage.editConnection(testConnection.connection_id, updatedConnection); + + // Verify the connection was updated + // await connectionsPage.navigate(); + exists = await connectionsPage.connectionExists(testConnection.connection_id); + expect(exists).toBeTruthy(); + }); + + test("should delete a connection", async () => { + // Create a temporary connection for deletion test + const tempConnection = { + conn_type: "postgres", + connection_id: `temp_conn_${timestamp}_delete`, + host: `temp-host-${timestamp}.example.com`, + login: "temp_user", + password: "temp_password", + }; + + await connectionsPage.navigate(); + await connectionsPage.createConnection(tempConnection); + let exists = await connectionsPage.connectionExists(tempConnection.connection_id); + + expect(exists).toBeTruthy(); + + // Delete the connection + await connectionsPage.deleteConnection(tempConnection.connection_id); + exists = await connectionsPage.connectionExists(tempConnection.connection_id); + expect(exists).toBeFalsy(); + }); +}); + +test.describe("Connections Page - Pagination", () => { + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Create multiple test connections to ensure we have enough for pagination testing + const testConnections = Array.from({ length: 5 }, (_, i) => ({ + conn_type: "http", + connection_id: `pagination_test_${timestamp}_${i}`, + host: `pagination-host-${i}.example.com`, + login: `pagination_user_${i}`, + })); + + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); + + test.beforeAll(async ({ browser }) => { + // Create multiple test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { + data: JSON.stringify(conn), + headers: { + "Content-Type": "application/json", + }, + }); + + // Connection may already exist + expect([200, 201, 409]).toContain(response.status()); + } + }); + + test.afterAll(async ({ browser }) => { + // Cleanup all test connections + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const conn of testConnections) { + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); + + expect([204, 404]).toContain(response.status()); + } + }); + + test("should display pagination controls when applicable", async () => { + await connectionsPage.navigate(); + + const hasPagination = await connectionsPage.isPaginationVisible(); + + expect(typeof hasPagination).toBe("boolean"); + }); + + test("should navigate to next page and verify data changes", async () => { + await connectionsPage.navigate(); + const hasPagination = await connectionsPage.isPaginationVisible(); + + if (hasPagination) { Review Comment: https://github.com/apache/airflow/blob/5c161a49ac811191428815f1d73981d11b3d17e5/airflow-core/src/airflow/ui/tests/e2e/specs/dags-list.spec.ts#L30 -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
