vatsrahul1001 commented on code in PR #60738: URL: https://github.com/apache/airflow/pull/60738#discussion_r2793916241
########## airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts: ########## @@ -0,0 +1,558 @@ +/*! + * 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: `test_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 () => { + test.setTimeout(60_000); // 1 minute for slower browsers like Firefox + 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 () => { + test.setTimeout(60_000); // 1 minute for slower browsers like Firefox + 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 () => { + test.setTimeout(60_000); // 1 minute for slower browsers like Firefox + + // 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: 60 }, (_, i) => ({ Review Comment: Why do we need 60 connections? We can use offset and limit in params to enforce pagination lile we did in other tests `await connectionsPage.navigateTo("/connections?offset=0&limit=2");` ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,622 @@ +/*! + * 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 confirmDeleteButton: Locator; + public readonly connectionForm: 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 emptyState: 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'); + this.emptyState = page.locator("text=/No connection found!/i"); + + // 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.connectionForm = page.locator('[data-scope="dialog"][data-part="content"]'); + 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(); + + // Pagination + this.paginationNextButton = page.locator('[data-testid="next"]'); + this.paginationPrevButton = page.locator('[data-testid="prev"]'); + 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 expect(this.addButton).toBeEnabled({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await expect(this.connectionForm).toBeVisible({ timeout: 10_000 }); + } + + // 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: 5000 }); + await expect(editButton).toBeEnabled({ timeout: 5000 }); + await editButton.click(); + await expect(this.connectionForm).toBeVisible({ timeout: 10_000 }); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 3000 }).catch(() => false); + + if (!isEnabled) { Review Comment: This will silently return in case of an actual issue, we should not even use it. I see in beforeAll we are creating sufficient connections so pagination should be enabled ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,622 @@ +/*! + * 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 confirmDeleteButton: Locator; + public readonly connectionForm: 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 emptyState: 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'); + this.emptyState = page.locator("text=/No connection found!/i"); + + // 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.connectionForm = page.locator('[data-scope="dialog"][data-part="content"]'); + 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(); + + // Pagination + this.paginationNextButton = page.locator('[data-testid="next"]'); + this.paginationPrevButton = page.locator('[data-testid="prev"]'); + 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 expect(this.addButton).toBeEnabled({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await expect(this.connectionForm).toBeVisible({ timeout: 10_000 }); + } + + // 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: 5000 }); + await expect(editButton).toBeEnabled({ timeout: 5000 }); + await editButton.click(); + await expect(this.connectionForm).toBeVisible({ timeout: 10_000 }); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 3000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + const initialIds = await this.getConnectionIds(); + + // Set up API listener before action + const responsePromise = this.page + .waitForResponse((resp) => resp.url().includes("/connections") && resp.status() === 200, { + timeout: 10_000, + }) + .catch(() => { Review Comment: why we need this? ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,622 @@ +/*! + * 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 confirmDeleteButton: Locator; + public readonly connectionForm: 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 emptyState: 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'); + this.emptyState = page.locator("text=/No connection found!/i"); + + // 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.connectionForm = page.locator('[data-scope="dialog"][data-part="content"]'); + 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(); + + // Pagination + this.paginationNextButton = page.locator('[data-testid="next"]'); + this.paginationPrevButton = page.locator('[data-testid="prev"]'); + 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 expect(this.addButton).toBeEnabled({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await expect(this.connectionForm).toBeVisible({ timeout: 10_000 }); + } + + // 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: 5000 }); + await expect(editButton).toBeEnabled({ timeout: 5000 }); + await editButton.click(); + await expect(this.connectionForm).toBeVisible({ timeout: 10_000 }); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 3000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + const initialIds = await this.getConnectionIds(); + + // Set up API listener before action + const responsePromise = this.page + .waitForResponse((resp) => resp.url().includes("/connections") && resp.status() === 200, { + timeout: 10_000, + }) + .catch(() => { + /* API might be cached */ + }); + + await expect(this.paginationNextButton).toBeVisible({ timeout: 5000 }); + await this.paginationNextButton.click(); + + // Wait for API response + await responsePromise; + + // Wait for UI to actually change + await expect + .poll(() => this.getConnectionIds(), { + message: "List did not update after clicking next page", + timeout: 10_000, + }) + .not.toEqual(initialIds); + } + + public async clickPrevPage(): Promise<void> { + const isEnabled = await this.paginationPrevButton.isEnabled({ timeout: 3000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + const initialIds = await this.getConnectionIds(); + + // Set up API listener before action + const responsePromise = this.page + .waitForResponse((resp) => resp.url().includes("/connections") && resp.status() === 200, { + timeout: 10_000, + }) + .catch(() => { Review Comment: why we need this? ########## airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts: ########## @@ -0,0 +1,622 @@ +/*! + * 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 confirmDeleteButton: Locator; + public readonly connectionForm: 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 emptyState: 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'); + this.emptyState = page.locator("text=/No connection found!/i"); + + // 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.connectionForm = page.locator('[data-scope="dialog"][data-part="content"]'); + 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(); + + // Pagination + this.paginationNextButton = page.locator('[data-testid="next"]'); + this.paginationPrevButton = page.locator('[data-testid="prev"]'); + 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 expect(this.addButton).toBeEnabled({ timeout: 5000 }); + await this.addButton.click(); + // Wait for form to load + await expect(this.connectionForm).toBeVisible({ timeout: 10_000 }); + } + + // 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: 5000 }); + await expect(editButton).toBeEnabled({ timeout: 5000 }); + await editButton.click(); + await expect(this.connectionForm).toBeVisible({ timeout: 10_000 }); + } + + public async clickNextPage(): Promise<void> { + const isEnabled = await this.paginationNextButton.isEnabled({ timeout: 3000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + const initialIds = await this.getConnectionIds(); + + // Set up API listener before action + const responsePromise = this.page + .waitForResponse((resp) => resp.url().includes("/connections") && resp.status() === 200, { + timeout: 10_000, + }) + .catch(() => { + /* API might be cached */ + }); + + await expect(this.paginationNextButton).toBeVisible({ timeout: 5000 }); + await this.paginationNextButton.click(); + + // Wait for API response + await responsePromise; + + // Wait for UI to actually change + await expect + .poll(() => this.getConnectionIds(), { + message: "List did not update after clicking next page", + timeout: 10_000, + }) + .not.toEqual(initialIds); + } + + public async clickPrevPage(): Promise<void> { + const isEnabled = await this.paginationPrevButton.isEnabled({ timeout: 3000 }).catch(() => false); + + if (!isEnabled) { + return; + } + + const initialIds = await this.getConnectionIds(); + + // Set up API listener before action + const responsePromise = this.page + .waitForResponse((resp) => resp.url().includes("/connections") && resp.status() === 200, { + timeout: 10_000, + }) + .catch(() => { + /* API might be cached */ + }); + + await expect(this.paginationPrevButton).toBeVisible({ timeout: 5000 }); + await this.paginationPrevButton.click(); + + // Wait for API response + await responsePromise; + + // Wait for UI to actually change + await expect + .poll(() => this.getConnectionIds(), { + message: "List did not update after clicking prev page", + timeout: 10_000, + }) + .not.toEqual(initialIds); + } + + // 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) { + 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(); + await this.waitForConnectionsListLoad(); + } + + // 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") { + backdrop.remove(); + } + }); + }); + const deleteButton = row.getByRole("button", { name: "Delete Connection" }); + + await expect(deleteButton).toBeVisible({ timeout: 10_000 }); + await expect(deleteButton).toBeEnabled({ timeout: 5000 }); + await deleteButton.click(); + + await expect(this.confirmDeleteButton).toBeVisible({ timeout: 10_000 }); + await expect(this.confirmDeleteButton).toBeEnabled({ timeout: 5000 }); + await this.confirmDeleteButton.click(); + + await expect(this.emptyState).toBeVisible({ timeout: 5000 }); + } + + // 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 expect(this.connectionIdInput).toBeVisible({ timeout: 10_000 }); + + // Fill the fields that need updating + await this.fillConnectionForm(updates); + await this.saveConnection(); + } + + // 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 expect(selectCombobox).toBeEnabled({ timeout: 10_000 }); + + await selectCombobox.click({ timeout: 3000 }).catch(() => { Review Comment: Why we need this? -- 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]
