diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts index a1cd32d3dce7f..67a2668d6052e 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/ConnectionsPage.ts @@ -18,7 +18,6 @@ */ import { expect, type Locator, type Page } from "@playwright/test"; import { BasePage } from "tests/e2e/pages/BasePage"; -import { waitForStableRowCount } from "tests/e2e/utils/test-helpers"; type ConnectionDetails = { conn_type: string; @@ -38,10 +37,11 @@ export class ConnectionsPage extends BasePage { } public readonly addButton: Locator; - public readonly confirmDeleteButton: Locator; public readonly connectionForm: Locator; public readonly connectionIdHeader: Locator; public readonly connectionIdInput: Locator; + public readonly connectionRows: Locator; + // Core page elements public readonly connectionsTable: Locator; public readonly connectionTypeHeader: Locator; public readonly descriptionInput: Locator; @@ -49,12 +49,12 @@ export class ConnectionsPage extends BasePage { public readonly hostHeader: Locator; public readonly hostInput: Locator; public readonly loginInput: Locator; - public readonly passwordInput: Locator; + public readonly passwordInput: Locator; public readonly portInput: Locator; public readonly rowsPerPageSelect: Locator; - public readonly saveButton: Locator; + public readonly saveButton: Locator; public readonly schemaInput: Locator; public readonly searchInput: Locator; public readonly successAlert: Locator; @@ -67,7 +67,7 @@ export class ConnectionsPage extends BasePage { this.emptyState = page.getByText(/no connection found/i); this.addButton = page.getByRole("button", { name: "Add Connection" }); - this.testConnectionButton = page.getByRole("button", { name: /^test$/i }); + this.testConnectionButton = page.getByRole("button", { name: "Test" }); this.saveButton = page.getByRole("button", { name: /^save$/i }); // Scoped via input[name] because Chakra UI forms may lack @@ -80,27 +80,33 @@ export class ConnectionsPage extends BasePage { this.passwordInput = page.locator('input[name="password"]').first(); this.schemaInput = page.locator('input[name="schema"]').first(); this.descriptionInput = page.locator('[name="description"]').first(); + // Alerts + this.successAlert = page.locator('[data-scope="toast"][data-part="root"]'); - // Alerts — scoped to the notification region to avoid Monaco editor's role="alert" elements - this.successAlert = page.getByRole("region", { name: /notifications/i }).getByRole("status"); - - // Button text is "Yes, Delete" (not just "Delete") - this.confirmDeleteButton = page.getByRole("button", { name: /yes, delete/i }); this.rowsPerPageSelect = page.locator("select"); - // columnheader accessible name is "sort" (from inner button), so filter by text instead. - this.tableHeader = page.getByRole("columnheader").first(); - this.connectionIdHeader = page.getByRole("columnheader").filter({ hasText: "Connection ID" }); - this.connectionTypeHeader = page.getByRole("columnheader").filter({ hasText: "Connection Type" }); - this.hostHeader = page.getByRole("columnheader").filter({ hasText: /^Host$/ }); - this.searchInput = page.getByPlaceholder(/search/i); + // Sorting and filtering + this.tableHeader = this.connectionsTable.getByRole("columnheader").first(); + + this.connectionIdHeader = this.connectionsTable + .getByRole("columnheader") + .filter({ hasText: "Connection ID" }); + this.connectionTypeHeader = this.connectionsTable + .getByRole("columnheader") + .filter({ hasText: "Connection Type" }); + this.hostHeader = this.connectionsTable.getByRole("columnheader").filter({ hasText: "Host" }); + + this.searchInput = page.getByPlaceholder(/search/i).first(); + // All table body rows (used by connectionRows for web-first assertions) + this.connectionRows = page.locator("tbody tr"); } public async clickAddButton(): Promise { await expect(this.addButton).toBeVisible({ timeout: 5000 }); await expect(this.addButton).toBeEnabled({ timeout: 5000 }); await this.addButton.click(); - await expect(this.connectionForm).toBeVisible(); + // Wait for form to load + await expect(this.connectionIdInput).toBeVisible({ timeout: 10_000 }); } public async clickEditButton(connectionId: string): Promise { @@ -115,19 +121,10 @@ export class ConnectionsPage extends BasePage { await expect(editButton).toBeVisible({ timeout: 5000 }); await expect(editButton).toBeEnabled({ timeout: 5000 }); await editButton.click(); - await expect(this.connectionForm).toBeVisible(); + await expect(this.connectionForm).toBeVisible({ timeout: 10_000 }); + await expect(this.connectionIdInput).toBeVisible({ timeout: 10_000 }); } - - public async connectionExists(connectionId: string): Promise { - const emptyState = await this.emptyState.isVisible({ timeout: 1000 }).catch(() => false); - - if (emptyState) { - return false; - } - - return (await this.findConnectionRow(connectionId)) !== undefined; - } - + // Create a new connection with full workflow public async createConnection(details: ConnectionDetails): Promise { await this.clickAddButton(); await this.fillConnectionForm(details); @@ -144,30 +141,27 @@ export class ConnectionsPage extends BasePage { const deleteButton = row.getByRole("button", { name: "Delete Connection" }); - await expect(deleteButton).toBeVisible(); + await expect(deleteButton).toBeVisible({ timeout: 10_000 }); await expect(deleteButton).toBeEnabled({ timeout: 5000 }); + await deleteButton.click(); + + const deleteDialog = this.page.locator('[role="dialog"][data-state="open"]'); - // Extended timeout: on resource-constrained CI runners, WebKit can take - // longer than the default 10s to release the previous dialog's backdrop - // pointer-events after close. - await deleteButton.click({ timeout: 30_000 }); + await expect(deleteDialog).toBeVisible({ timeout: 10_000 }); - await expect(this.confirmDeleteButton).toBeVisible(); - await expect(this.confirmDeleteButton).toBeEnabled({ timeout: 5000 }); - await this.confirmDeleteButton.click(); + await deleteDialog.getByRole("button", { name: "Yes, Delete" }).click(); - await expect(this.emptyState).toBeVisible({ timeout: 5000 }); + await expect(this.getConnectionRow(connectionId)).not.toBeVisible({ timeout: 15_000 }); } public async editConnection(connectionId: string, updates: Partial): Promise { - const row = await this.findConnectionRow(connectionId); + await this.clickEditButton(connectionId); - if (!row) { - throw new Error(`Connection ${connectionId} not found`); - } + await expect(this.connectionIdInput).toBeVisible({ timeout: 10_000 }); + await expect(this.connectionIdInput).toBeEnabled({ timeout: 10_000 }); + await expect(this.connectionIdInput).toHaveValue(connectionId, { timeout: 10_000 }); - await this.clickEditButton(connectionId); - await expect(this.connectionIdInput).toBeVisible(); + // Fill the fields that need updating await this.fillConnectionForm(updates); await this.saveConnection(); } @@ -178,52 +172,54 @@ export class ConnectionsPage extends BasePage { } if (details.conn_type !== undefined && details.conn_type !== "") { - const selectInput = this.connectionForm.locator('[role="combobox"]').first(); + // Scope the combobox to the form dialog to avoid matching stale elements + // outside the form. Wait for it to become actionable before opening the + // list, which avoids races when the dialog is still settling. + const selectCombobox = this.connectionForm.getByRole("combobox").first(); + const option = this.page.getByRole("option", { name: new RegExp(details.conn_type, "i") }).first(); await expect(async () => { - await expect(selectInput).toBeEnabled(); - await selectInput.click({ force: true, timeout: 5000 }); - }).toPass({ intervals: [2000, 3000], timeout: 120_000 }); - - await this.page.keyboard.type(details.conn_type); - - const option = this.page.getByRole("option", { name: new RegExp(details.conn_type, "i") }).first(); + await expect(selectCombobox).toBeVisible({ timeout: 10_000 }); + await expect(selectCombobox).toBeEnabled({ timeout: 10_000 }); + await selectCombobox.click({ timeout: 5000 }); + await expect(option).toBeVisible({ timeout: 10_000 }); + }).toPass({ intervals: [1000, 2000, 3000], timeout: 30_000 }); await option.click(); } if (details.host !== undefined && details.host !== "") { - await expect(this.hostInput).toBeVisible(); + await expect(this.hostInput).toBeVisible({ timeout: 10_000 }); await this.hostInput.fill(details.host); } if (details.port !== undefined && details.port !== "") { - await expect(this.portInput).toBeVisible(); + await expect(this.portInput).toBeVisible({ timeout: 10_000 }); await this.portInput.fill(String(details.port)); } if (details.login !== undefined && details.login !== "") { - await expect(this.loginInput).toBeVisible(); + await expect(this.loginInput).toBeVisible({ timeout: 10_000 }); await this.loginInput.fill(details.login); } if (details.password !== undefined && details.password !== "") { - await expect(this.passwordInput).toBeVisible(); + await expect(this.passwordInput).toBeVisible({ timeout: 10_000 }); await this.passwordInput.fill(details.password); } if (details.description !== undefined && details.description !== "") { - await expect(this.descriptionInput).toBeVisible(); + await expect(this.descriptionInput).toBeVisible({ timeout: 10_000 }); await this.descriptionInput.fill(details.description); } if (details.schema !== undefined && details.schema !== "") { - await expect(this.schemaInput).toBeVisible(); + await expect(this.schemaInput).toBeVisible({ timeout: 10_000 }); await this.schemaInput.fill(details.schema); } if (details.extra !== undefined && details.extra !== "") { - const extraAccordion = this.page.getByRole("button", { name: /extra fields json/i }); + const extraAccordion = this.page.getByRole("button", { name: "Extra Fields JSON" }).first(); const accordionVisible = await extraAccordion.isVisible({ timeout: 5000 }).catch(() => false); if (accordionVisible) { @@ -263,30 +259,61 @@ export class ConnectionsPage extends BasePage { } public async getConnectionIds(): Promise> { - const rowLocator = this.page.locator("tbody tr"); - const stableRowCount = await waitForStableRowCount(rowLocator).catch(() => 0); + const rowLocator = this.connectionRows; + const countRow = await rowLocator.count(); - if (stableRowCount === 0) { + if (countRow === 0) { return []; } - const headerTexts = await this.page.locator("thead th").allTextContents(); - const idColumnIndex = headerTexts.findIndex((text) => /connection\s*id/i.test(text)); + await expect(rowLocator.first()).toBeVisible({ timeout: 5000 }); + + let stableRowCount = 0; + let lastSeenCount = -1; + + await expect + .poll( + async () => { + const count = await this.page.locator("tbody tr").count(); + + if (count > 0 && count === lastSeenCount) { + stableRowCount = count; + + return true; + } + lastSeenCount = count; + await this.page.waitForTimeout(200); + + return false; + }, + { intervals: [100, 200, 500, 500], timeout: 10_000 }, + ) + .toBeTruthy() + .catch(() => { + // If timeout, fall back to last observed count + stableRowCount = lastSeenCount > 0 ? lastSeenCount : 0; + }); - if (idColumnIndex === -1) { - throw new Error(`"Connection ID" column not found in headers: ${JSON.stringify(headerTexts)}`); + if (stableRowCount === 0) { + return []; } - const rows = this.page.locator("tbody tr"); const connectionIds: Array = []; for (let i = 0; i < stableRowCount; i++) { try { - const cell = rows.nth(i).locator("td").nth(idColumnIndex); - const text = await cell.textContent({ timeout: 3000 }); + const row = rowLocator.nth(i); + const cells = row.locator("td"); + const cellCount = await cells.count(); - if (text !== null && text.trim() !== "") { - connectionIds.push(text.trim()); + if (cellCount > 1) { + // Second cell (after checkbox) contains the connection ID. + const idCell = cells.nth(1); + const text = await idCell.textContent({ timeout: 3000 }); + + if (text !== null && text.trim() !== "") { + connectionIds.push(text.trim()); + } } } catch { continue; @@ -296,6 +323,12 @@ export class ConnectionsPage extends BasePage { return connectionIds; } + // Returns a locator for a specific connection row (for web-first assertions in specs) + public getConnectionRow(connectionId: string): Locator { + return this.page.locator("tbody tr").filter({ hasText: connectionId }).first(); + } + + // Navigate to Connections list page public async navigate(): Promise { await expect(async () => { await this.navigateTo(ConnectionsPage.connectionsListUrl); @@ -304,7 +337,7 @@ export class ConnectionsPage extends BasePage { } public async saveConnection(): Promise { - await expect(this.saveButton).toBeVisible(); + await expect(this.saveButton).toBeVisible({ timeout: 10_000 }); await expect(this.saveButton).toBeEnabled({ timeout: 5000 }); const responsePromise = this.page.waitForResponse( @@ -320,34 +353,18 @@ export class ConnectionsPage extends BasePage { } public async searchConnections(searchTerm: string): Promise { - await expect(async () => { - await this.searchInput.fill(searchTerm); - await expect(this.searchInput).toHaveValue(searchTerm); - }).toPass({ intervals: [1000, 2000], timeout: 30_000 }); - - await expect - .poll( - async () => { - const ids = await this.getConnectionIds(); - const isEmptyVisible = await this.emptyState.isVisible().catch(() => false); - - if (isEmptyVisible) { - return ids.length === 0; - } + await this.searchInput.fill(searchTerm); - if (ids.length === 0) { - return false; - } - - if (searchTerm === "") { - return ids.length > 0; - } + if (searchTerm === "") { + await expect(this.connectionRows.first().or(this.emptyState)).toBeVisible({ timeout: 10_000 }); + } else { + // Wait for a matching row or the empty state to appear — this directly checks + // what the user sees and avoids a race where an empty loading state satisfies + // "no non-matching rows" before results arrive. + const matchingRow = this.page.locator("tbody tr").filter({ hasText: searchTerm }); - return ids.every((id) => id.toLowerCase().includes(searchTerm.toLowerCase())); - }, - { message: "Search results did not match search term", timeout: 30_000 }, - ) - .toBeTruthy(); + await expect(matchingRow.first().or(this.emptyState)).toBeVisible({ timeout: 10_000 }); + } } public async verifyConnectionInList(connectionId: string, expectedType: string): Promise { @@ -357,14 +374,16 @@ export class ConnectionsPage extends BasePage { throw new Error(`Connection ${connectionId} not found in list`); } - const rowText = await row.textContent(); - - expect(rowText).toContain(connectionId); - expect(rowText).toContain(expectedType); + await expect(row).toContainText(connectionId); + await expect(row).toContainText(expectedType); } private async findConnectionRow(connectionId: string): Promise { - const hasSearch = await this.searchInput.isVisible({ timeout: 500 }).catch(() => false); + // Try search first (faster) + const hasSearch = await this.searchInput + .waitFor({ state: "visible", timeout: 3000 }) + .then(() => true) + .catch(() => false); if (hasSearch) { return await this.findConnectionRowUsingSearch(connectionId); @@ -374,6 +393,7 @@ export class ConnectionsPage extends BasePage { } private async findConnectionRowUsingSearch(connectionId: string): Promise { + await this.waitForConnectionsListLoad(); await this.searchConnections(connectionId); const isTableVisible = await this.connectionsTable.isVisible({ timeout: 5000 }).catch(() => false); @@ -384,9 +404,10 @@ export class ConnectionsPage extends BasePage { const row = this.page.locator("tbody tr").filter({ hasText: connectionId }).first(); - const rowExists = await row.isVisible({ timeout: 3000 }).catch(() => false); - - if (!rowExists) { + // Use web-first assertion (toBeVisible) rather than manual isVisible() check + try { + await expect(row).toBeVisible({ timeout: 10_000 }); + } catch { return undefined; } @@ -394,19 +415,17 @@ export class ConnectionsPage extends BasePage { } private async waitForConnectionsListLoad(): Promise { - await expect(this.page).toHaveURL(/\/connections/); + await expect(this.page).toHaveURL(/\/connections/, { timeout: 10_000 }); await this.page.waitForLoadState("domcontentloaded"); const table = this.connectionsTable; - await expect(table.or(this.emptyState)).toBeVisible(); - - const isTableVisible = await table.isVisible(); - - if (isTableVisible) { - const firstRow = this.page.locator("tbody tr").first(); + await expect(table.or(this.emptyState)).toBeVisible({ timeout: 10_000 }); - await expect(firstRow.or(this.emptyState)).toBeVisible({ timeout: 15_000 }); + if (await table.isVisible().catch(() => false)) { + await expect(this.connectionRows.first()).toBeVisible({ + timeout: 10_000, + }); } } } diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts index 99eda097e6397..b59d72c6737e0 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/connections.spec.ts @@ -16,171 +16,181 @@ * specific language governing permissions and limitations * under the License. */ -import { expect, test } from "tests/e2e/fixtures"; -import { uniqueRunId } from "tests/e2e/utils/test-helpers"; +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 seedConnection: { conn_type: string; connection_id: string; host: string }; + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + const seedConnection = { + conn_type: "http", + connection_id: `list_seed_conn_${timestamp}`, + host: "seed.example.com", + }; - test.beforeAll(async ({ authenticatedRequest }) => { - seedConnection = { - conn_type: "http", - connection_id: `list_seed_conn_${uniqueRunId("t")}`, - host: "seed.example.com", - }; + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); - const response = await authenticatedRequest.post(`/api/v2/connections`, { + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { data: seedConnection, headers: { "Content-Type": "application/json" }, - timeout: 30_000, }); expect([200, 201, 409]).toContain(response.status()); + await context.close(); }); - test.afterAll(async ({ authenticatedRequest }) => { - const response = await authenticatedRequest.delete( - `/api/v2/connections/${seedConnection.connection_id}`, - { timeout: 30_000 }, - ); + test.afterAll(async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); - if (response.status() !== 404 && !response.ok()) { - console.warn(`Cleanup failed for ${seedConnection.connection_id}: ${response.status()}`); - } + const response = await page.request.delete(`${baseUrl}/api/v2/connections/list_seed_conn_${timestamp}`); + + expect([204, 404]).toContain(response.status()); + await context.close(); }); - test("should display connections list page", async ({ connectionsPage }) => { + test("should display connections list page", async () => { await connectionsPage.navigate(); - expect(connectionsPage.page.url()).toContain("/connections"); + // Verify the page is loaded + await expect(connectionsPage.page).toHaveURL(/\/connections/); + // Verify table or list is visible await expect(connectionsPage.connectionsTable).toBeVisible(); }); - test("should display connections with correct columns", async ({ connectionsPage }) => { + test("should display connections with correct columns", async () => { await connectionsPage.navigate(); - await expect - .poll(async () => connectionsPage.getConnectionCount(), { intervals: [1000], timeout: 15_000 }) - .toBeGreaterThan(0); + // Check that we have at least one row + await expect(connectionsPage.connectionRows).not.toHaveCount(0); + // Verify column headers exist await expect(connectionsPage.connectionIdHeader).toBeVisible(); await expect(connectionsPage.connectionTypeHeader).toBeVisible(); await expect(connectionsPage.hostHeader).toBeVisible(); + // Verify the seed connection is displayed in the list await connectionsPage.verifyConnectionInList(seedConnection.connection_id, seedConnection.conn_type); }); - test("should have Add button visible", async ({ connectionsPage }) => { + test("should have Add button visible", async () => { await connectionsPage.navigate(); await expect(connectionsPage.addButton).toBeVisible(); }); }); test.describe("Connections Page - CRUD Operations", () => { - let existingConnection: { conn_type: string; connection_id: string; host: string; login: string }; - let updatedConnection: { - conn_type: string; - description: string; - host: string; - login: string; - port: number; + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + // Connection created via API in beforeAll - used for edit and display tests + const existingConnection = { + conn_type: "postgres", + connection_id: `existing_conn_${timestamp}`, + host: `existing-host-${timestamp}.example.com`, + login: `existing_user_${timestamp}`, }; - const createdConnIds: Array = []; + const updatedConnection = { + conn_type: "postgres", + description: `Updated test connection at ${new Date().toISOString()}`, + host: `updated-host-${timestamp}.example.com`, + login: `updated_user_${timestamp}`, + port: 5433, + }; - test.beforeAll(async ({ authenticatedRequest }) => { - const timestamp = uniqueRunId("t"); + // Connection created via UI in test - used for create and delete tests + const newConnection = { + conn_type: "postgres", + connection_id: `new_conn_${timestamp}`, + description: `Test connection created at ${new Date().toISOString()}`, + extra: JSON.stringify({ + options: "-c statement_timeout=5000", + sslmode: "require", + }), + host: `new-host-${timestamp}.example.com`, + login: `new_user_${timestamp}`, + password: `new_password_${timestamp}`, + port: 5432, + schema: "test_db", + }; - existingConnection = { - conn_type: "postgres", - connection_id: `existing_conn_${timestamp}`, - host: `existing-host-${timestamp}.example.com`, - login: `existing_user_${timestamp}`, - }; + test.beforeEach(({ page }) => { + connectionsPage = new ConnectionsPage(page); + }); - updatedConnection = { - conn_type: "postgres", - description: `Updated test connection at ${new Date().toISOString()}`, - host: `updated-host-${timestamp}.example.com`, - login: `updated_user_${timestamp}`, - port: 5433, - }; + test.beforeAll(async ({ browser }) => { + // Create existing connection via API for edit and display tests + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); - await authenticatedRequest.post(`/api/v2/connections`, { + await page.request.post(`${baseUrl}/api/v2/connections`, { data: existingConnection, headers: { "Content-Type": "application/json" }, - timeout: 30_000, }); - }); - - test.afterAll(async ({ authenticatedRequest }) => { - for (const connId of [existingConnection.connection_id, ...createdConnIds]) { - const response = await authenticatedRequest.delete(`/api/v2/connections/${connId}`, { - timeout: 30_000, - }); - if (response.status() !== 404 && !response.ok()) { - console.warn(`Cleanup failed for ${connId}: ${response.status()}`); - } - } + await context.close(); }); - test("should create a new connection and display it in list", async ({ connectionsPage }) => { - test.slow(); - - const connId = `new_conn_${uniqueRunId("create")}`; - - createdConnIds.push(connId); + test.afterAll(async ({ browser }) => { + // Cleanup all test connections via API + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + for (const connId of [ + existingConnection.connection_id, + newConnection.connection_id, + `temp_conn_${timestamp}_delete`, + ]) { + await page.request.delete(`${baseUrl}/api/v2/connections/${connId}`); + } - const newConnection = { - conn_type: "postgres", - connection_id: connId, - description: `Test connection created at ${new Date().toISOString()}`, - extra: JSON.stringify({ - options: "-c statement_timeout=5000", - sslmode: "require", - }), - host: `new-host-${connId}.example.com`, - login: `new_user_${connId}`, - password: `new_password_${connId}`, - port: 5432, - schema: "test_db", - }; + await context.close(); + }); + test.fixme("should create a new connection and display it in list", async () => { + test.setTimeout(120_000); await connectionsPage.navigate(); - await connectionsPage.createConnection(newConnection); - const exists = await connectionsPage.connectionExists(newConnection.connection_id); + // Create connection via UI + await connectionsPage.createConnection(newConnection); - expect(exists).toBeTruthy(); + // Verify it appears in the list with correct type (web-first assertion) await connectionsPage.verifyConnectionInList(newConnection.connection_id, newConnection.conn_type); }); - test("should edit an existing connection", async ({ connectionsPage }) => { - test.slow(); + test.fixme("should edit an existing connection", async () => { + test.setTimeout(120_000); await connectionsPage.navigate(); - const exists = await connectionsPage.connectionExists(existingConnection.connection_id); - - expect(exists).toBeTruthy(); + // Verify connection exists before editing (web-first assertion) + await expect(connectionsPage.getConnectionRow(existingConnection.connection_id)).toBeVisible(); + // Edit the connection await connectionsPage.editConnection(existingConnection.connection_id, updatedConnection); - const stillExists = await connectionsPage.connectionExists(existingConnection.connection_id); - - expect(stillExists).toBeTruthy(); + // Verify the connection still exists after editing (web-first assertion) + await expect(connectionsPage.getConnectionRow(existingConnection.connection_id)).toBeVisible(); }); - test("should delete a connection", async ({ connectionsPage }) => { - test.slow(); - - const tempConnId = `temp_conn_${uniqueRunId("del")}`; + test("should delete a connection", async () => { + test.setTimeout(120_000); + // Create a temporary connection for deletion test const tempConnection = { conn_type: "postgres", - connection_id: tempConnId, - host: "temp-host.example.com", + connection_id: `temp_conn_${timestamp}_delete`, + host: `temp-host-${timestamp}.example.com`, login: "temp_user", password: "temp_password", }; @@ -188,138 +198,116 @@ test.describe("Connections Page - CRUD Operations", () => { await connectionsPage.navigate(); await connectionsPage.createConnection(tempConnection); - const exists = await connectionsPage.connectionExists(tempConnection.connection_id); - - expect(exists).toBeTruthy(); + // Verify it exists before deleting (web-first assertion) + await expect(connectionsPage.getConnectionRow(tempConnection.connection_id)).toBeVisible(); + // Delete the connection await connectionsPage.deleteConnection(tempConnection.connection_id); - const stillExists = await connectionsPage.connectionExists(tempConnection.connection_id); - - expect(stillExists).toBeFalsy(); + // Verify it is gone (web-first assertion) + await expect(connectionsPage.getConnectionRow(tempConnection.connection_id)).not.toBeVisible(); }); }); test.describe("Connections Page - Search and Filter", () => { - let searchTestConnections: Array<{ - conn_type: string; - connection_id: string; - host: string; - login: string; - }>; - - test.beforeAll(async ({ authenticatedRequest }) => { - const timestamp = uniqueRunId("t"); - - searchTestConnections = [ - { - conn_type: "postgres", - connection_id: `production_search_${timestamp}`, - host: "prod-db.example.com", - login: "prod_user", - }, - { - conn_type: "mysql", - connection_id: `staging_search_${timestamp}`, - host: "staging-db.example.com", - login: "staging_user", - }, - { - conn_type: "http", - connection_id: `development_search_${timestamp}`, - host: "dev-api.example.com", - login: "dev_user", - }, - ]; + let connectionsPage: ConnectionsPage; + const { baseUrl } = testConfig.connection; + const timestamp = Date.now(); + + const searchTestConnections = [ + { + conn_type: "postgres", + connection_id: `production_search_${timestamp}`, + host: "prod-db.example.com", + login: "prod_user", + }, + { + conn_type: "mysql", + connection_id: `staging_search_${timestamp}`, + host: "staging-db.example.com", + login: "staging_user", + }, + { + conn_type: "http", + connection_id: `development_search_${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 authenticatedRequest.post(`/api/v2/connections`, { + const response = await page.request.post(`${baseUrl}/api/v2/connections`, { data: JSON.stringify(conn), headers: { "Content-Type": "application/json", }, - timeout: 30_000, }); expect([200, 201, 409]).toContain(response.status()); } }); - test.afterAll(async ({ authenticatedRequest }) => { + 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 authenticatedRequest.delete(`/api/v2/connections/${conn.connection_id}`, { - timeout: 30_000, - }); + const response = await page.request.delete(`${baseUrl}/api/v2/connections/${conn.connection_id}`); - if (response.status() !== 404 && !response.ok()) { - console.warn(`Cleanup failed for ${conn.connection_id}: ${response.status()}`); - } + expect([204, 404]).toContain(response.status()); } }); - test("should filter connections by search term", async ({ connectionsPage }) => { + test("should filter connections by search term", async () => { await connectionsPage.navigate(); - await expect - .poll(async () => connectionsPage.getConnectionCount(), { intervals: [1000], timeout: 30_000 }) - .toBeGreaterThan(0); - - const searchTerm = "production"; + const targetConnection = searchTestConnections[0]!; + const searchTerm = targetConnection.connection_id; + // Check that we have at least one row before searching (web-first assertion) await connectionsPage.searchConnections(searchTerm); + await expect(connectionsPage.getConnectionRow(searchTerm)).toBeVisible(); await expect - .poll( - async () => { - const ids = await connectionsPage.getConnectionIds(); - - return ids.length > 0 && ids.every((id) => id.toLowerCase().includes(searchTerm.toLowerCase())); - }, - { intervals: [500] }, - ) + .poll(async () => { + const rowTexts = await connectionsPage.connectionRows.allTextContents(); + + return ( + rowTexts.length > 0 && + rowTexts.every((text) => text.toLowerCase().includes(searchTerm.toLowerCase())) + ); + }) .toBe(true); - - const filteredIds = await connectionsPage.getConnectionIds(); - - expect(filteredIds.length).toBeGreaterThan(0); - for (const id of filteredIds) { - expect(id.toLowerCase()).toContain(searchTerm.toLowerCase()); - } }); - test("should display all connections when search is cleared", async ({ connectionsPage }) => { - test.slow(); + test("should display all connections when search is cleared", async () => { + test.setTimeout(120_000); await connectionsPage.navigate(); - let initialCount = 0; - - await expect - .poll( - async () => { - initialCount = await connectionsPage.getConnectionCount(); - - return initialCount; - }, - { intervals: [1000], timeout: 30_000 }, - ) - .toBeGreaterThan(0); + // Verify rows exist before searching (web-first assertion) + await expect(connectionsPage.connectionRows).not.toHaveCount(0); + const initialCount = await connectionsPage.getConnectionCount(); + // Search for something and wait for results await connectionsPage.searchConnections("production"); - await expect - .poll( - async () => { - const count = await connectionsPage.getConnectionCount(); - - return count > 0; // Just verify we have some results - }, - { intervals: [500] }, - ) - .toBe(true); + await expect(connectionsPage.connectionRows).not.toHaveCount(0); + // Clear search and verify at least as many rows as before await connectionsPage.searchConnections(""); + await expect(async () => { + const finalCount = await connectionsPage.getConnectionCount(); - const finalCount = await connectionsPage.getConnectionCount(); - - expect(finalCount).toBeGreaterThanOrEqual(initialCount); + expect(finalCount).toBeGreaterThanOrEqual(initialCount); + }).toPass({ timeout: 10_000 }); }); });