From 0a0b230cf2ff86e6b83464ffdae937100572d5e4 Mon Sep 17 00:00:00 2001 From: Prajwal7842 Date: Mon, 5 Jan 2026 19:56:11 +0530 Subject: [PATCH 1/3] Add integration tests for /events Audit log page --- .../airflow/ui/tests/e2e/pages/EventsPage.ts | 228 ++++++++++++++++++ .../ui/tests/e2e/specs/events-page.spec.ts | 144 +++++++++++ 2 files changed, 372 insertions(+) create mode 100644 airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts create mode 100644 airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts new file mode 100644 index 0000000000000..6101c4dcffdda --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts @@ -0,0 +1,228 @@ +/*! + * 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"; + +/** + * Events Page Object + */ +export class EventsPage extends BasePage { + // Page URLs + public static get eventsListUrl(): string { + return "/events"; + } + + public static get eventsListPaginatedURL(): string { + return 'events?limit=1&offset=1' + } + + public readonly eventsPageTitle: Locator; + public readonly eventsTable: Locator; + public readonly filterBar: Locator; + public readonly timestampColumn: Locator; + public readonly eventTypeColumn: Locator; + public readonly dagIdColumn: Locator; + public readonly userColumn: Locator; + public readonly extraColumn: Locator; + public readonly runIdColumn: Locator; + public readonly taskIdColumn: Locator; + public readonly mapIndexColumn: Locator; + public readonly tryNumberColumn: Locator; + public readonly paginationNextButton: Locator; + public readonly paginationPrevButton: Locator; + + public constructor(page: Page) { + super(page); + this.eventsPageTitle = page.locator('h2:has-text("Audit Log")'); + this.eventsTable = page.getByRole("table"); + this.filterBar = page.locator('div:has(button:has-text("Filter"))').first(); + // Use table header selectors - will check for presence of key columns + this.timestampColumn = page.locator("thead th").first(); + this.eventTypeColumn = page.locator("thead th").nth(1); + this.userColumn = page.locator("thead th").nth(2); + this.extraColumn = page.locator("thead th").nth(3); + this.dagIdColumn = page.locator("thead th").nth(4); + this.runIdColumn = page.locator("thead th").nth(5); + this.taskIdColumn = page.locator("thead th").nth(6); + this.mapIndexColumn = page.locator("thead th").nth(7); + this.tryNumberColumn = page.locator("thead th").nth(8); + this.paginationNextButton = page.locator('[data-testid="next"]'); + this.paginationPrevButton = page.locator('[data-testid="prev"]'); + } + + /** + * Navigate to Events page + */ + public async navigate(): Promise { + await this.navigateTo(EventsPage.eventsListUrl); + } + + public async navigateToPaginatedEventsPage(): Promise { + await this.navigateTo(EventsPage.eventsListPaginatedURL); + } + + /** + * Wait for events table to be visible + */ + public async waitForEventsTable(): Promise { + await expect(this.eventsTable).toBeVisible({ timeout: 10_000 }); + } + + /** + * Get the number of table rows with data + */ + public async getTableRowCount(): Promise { + await this.waitForEventsTable(); + return await this.eventsTable.locator("tbody tr").count(); + } + + /** + * Get text content from a specific table cell + */ + public async getCellContent(rowIndex: number, columnIndex: number): Promise { + const cell = this.eventsTable.locator(`tbody tr:nth-child(${rowIndex + 1}) td:nth-child(${columnIndex + 1})`); + await expect(cell).toBeVisible(); + + // Try multiple methods to get cell content, as WebKit might behave differently + let content = await cell.textContent(); + + if (!content || content.trim() === "") { + // Fallback: try innerText + content = await cell.innerText().catch(() => ""); + } + + if (!content || content.trim() === "") { + // Fallback: try getting content from child elements + const childContent = await cell.locator("*").first().textContent().catch(() => ""); + content = childContent; + } + + return content || ""; + } + + /** + * Verify that log entries contain expected data patterns + */ + public async verifyLogEntriesWithData(): Promise { + const rowCount = await this.getTableRowCount(); + expect(rowCount).toBeGreaterThan(1); + + // Check event column (second column) - should contain event type + const eventText = await this.getCellContent(0, 1); + expect(eventText.trim()).not.toBe(""); + + // Check user column (third column) - should contain user info + const userText = await this.getCellContent(0, 2); + expect(userText.trim()).not.toBe(""); + } + + public async waitForTimeout(timeoutMs: number = 2000): Promise { + await this.page.waitForTimeout(timeoutMs); + } + + /** + * Click next page button + */ + public async clickNextPage(): Promise { + await expect(this.paginationNextButton).toBeEnabled(); + + // Get first row content before click to detect change + const firstRowBefore = await this.eventsTable.locator("tbody tr").first().textContent(); + + await this.paginationNextButton.click(); + + // Wait for table content to change (indicating pagination happened) + await this.page.waitForFunction( + (beforeContent) => { + const firstRow = document.querySelector('table tbody tr'); + return firstRow && firstRow.textContent !== beforeContent; + }, + firstRowBefore, + { timeout: 10000 } + ); + + await this.waitForEventsTable(); + } + + /** + * Click previous page button + */ + public async clickPrevPage(): Promise { + await this.paginationPrevButton.click(); + await this.waitForEventsTable(); + } + + /** + * Click on a column header to sort + */ + public async clickColumnHeader(columnIndex: number): Promise { + const header = this.eventsTable.locator(`thead th:nth-child(${columnIndex + 1})`); + await header.click(); + await this.waitForEventsTable(); + } + + /** + * Get sort indicator from column header + */ + public async getColumnSortIndicator(columnIndex: number): Promise { + const header = this.eventsTable.locator(`thead th:nth-child(${columnIndex + 1})`); + + // Check for SVG elements with sort-related aria-label + const sortSvg = header.locator('svg[aria-label*="sorted"]'); + const svgCount = await sortSvg.count(); + + if (svgCount > 0) { + const svgAriaLabel = await sortSvg.first().getAttribute('aria-label'); + + if (svgAriaLabel) { + if (svgAriaLabel.includes('ascending')) { + return 'ascending'; + } + if (svgAriaLabel.includes('descending')) { + return 'descending'; + } + } + } + + return 'none'; + } + + /** + * Verify that required table columns are visible + */ + public async verifyTableColumns(): Promise { + // Wait for table headers to be present + await expect(this.eventsTable.locator("thead")).toBeVisible(); + + // Verify we have table headers (at least 4 core columns) + const headerCount = await this.eventsTable.locator("thead th").count(); + expect(headerCount).toBeGreaterThanOrEqual(4); + + // Verify the first few essential columns are present + await expect(this.timestampColumn).toBeVisible(); + await expect(this.eventTypeColumn).toBeVisible(); + await expect(this.dagIdColumn).toBeVisible(); + await expect(this.userColumn).toBeVisible(); + await expect(this.extraColumn).toBeVisible(); + await expect(this.runIdColumn).toBeVisible(); + await expect(this.taskIdColumn).toBeVisible(); + await expect(this.mapIndexColumn).toBeVisible(); + await expect(this.tryNumberColumn).toBeVisible(); + } +} \ No newline at end of file diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts new file mode 100644 index 0000000000000..138901d8deb02 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts @@ -0,0 +1,144 @@ +/*! + * 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 } from "playwright.config"; +import { EventsPage } from "tests/e2e/pages/EventsPage"; +import { DagsPage } from "tests/e2e/pages/DagsPage"; + +test.describe("Events Page", () => { + let eventsPage: EventsPage; + + test.beforeEach(({ page }) => { + eventsPage = new EventsPage(page); + }); + + test("should verify search input is visible", async () => { + await eventsPage.navigate(); + await eventsPage.waitForEventsTable(); + + // Verify filter bar (containing search functionality) is visible + await expect(eventsPage.filterBar).toBeVisible({ timeout: 10_000 }); + + // Verify the filter button is present (allows adding search filters) + const filterButton = eventsPage.page.locator('button:has-text("Filter")'); + await expect(filterButton).toBeVisible(); + + // Click the filter button to open the filter menu + await filterButton.click(); + + // Verify filter menu opened - be more specific to target the filter menu + const filterMenu = eventsPage.page.locator('[role="menu"][aria-labelledby*="menu"][data-state="open"]'); + await expect(filterMenu).toBeVisible({ timeout: 5_000 }); + + // Look for text search options in the menu + const textSearchOptions = eventsPage.page.locator('[role="menuitem"]:has-text("DAG ID"), [role="menuitem"]:has-text("Event Type"), [role="menuitem"]:has-text("User")'); + await textSearchOptions.count() > 0; + await expect(textSearchOptions.first()).toBeVisible(); + + // Close the menu by pressing Escape + await eventsPage.page.keyboard.press('Escape'); + + // Wait a moment for menu to close + await eventsPage.page.waitForTimeout(500); + + }); + + test("should verify events page", async () => { + await eventsPage.navigate(); + + await expect(async () => { // To avoid flakiness, we use a promise to wait for the elements to be visible + await expect(eventsPage.eventsPageTitle).toBeVisible({ timeout: 10_000 }); + await eventsPage.waitForEventsTable(); + await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + await eventsPage.verifyTableColumns(); + }).toPass({ timeout: 30_000 }); + }); + +}); + +test.describe("Events with Generated Data", () => { + let eventsPage: EventsPage; + let dagsPage: DagsPage; + const testDagId = testConfig.testDag.id; + + test.beforeEach(({ page }) => { + eventsPage = new EventsPage(page); + dagsPage = new DagsPage(page); + }); + + test("should show audit log entries after triggering a DAG", async () => { + test.setTimeout(3 * 60 * 1000); // 3 minutes timeout + + // First, trigger a DAG to generate audit log entries + await dagsPage.triggerDag(testDagId); + + // Navigate to events page + await eventsPage.navigate(); + + // Wait for table to load + await eventsPage.waitForEventsTable(); + + // Wait for audit log entries to appear (they may take time to load within table) + await eventsPage.waitForTimeout(5000); + + // Verify the log entries contain actual data + await eventsPage.verifyLogEntriesWithData(); + + }); + + test("should verify pagination works with small page size", async () => { + // Navigate to events page with small page size to force pagination + await eventsPage.navigateToPaginatedEventsPage(); + + await eventsPage.waitForEventsTable(); + + await expect(eventsPage.paginationNextButton).toBeVisible({ timeout: 10_000 }); + await expect(eventsPage.paginationPrevButton).toBeVisible({ timeout: 10_000 }); + + // Test pagination functionality - should have enough data to enable next button + await expect(eventsPage.paginationNextButton).toBeEnabled({ timeout: 10_000 }); + + // Click next page - content should change + await eventsPage.clickNextPage(); + await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + + // Click prev page - content should change and previous button should be enabled₹ + await expect(eventsPage.paginationPrevButton).toBeEnabled({ timeout: 5_000 }); + + await eventsPage.clickPrevPage(); + await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + }); + + test("should verify column sorting works", async () => { + await eventsPage.navigate(); + await eventsPage.waitForEventsTable(); + + await eventsPage.waitForTimeout(5000); + + // Click first column header (Timestamp) to sort + await eventsPage.clickColumnHeader(0); + await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + + await eventsPage.waitForTimeout(5000); + + // Verify sort indicator shows ascending + const sortIndicator = await eventsPage.getColumnSortIndicator(0); + expect(sortIndicator).not.toBe("none"); + }); +}); \ No newline at end of file From 8b842f2ce86832d3c805d8f2aace856be90473e4 Mon Sep 17 00:00:00 2001 From: Prajwal7842 Date: Mon, 5 Jan 2026 20:15:21 +0530 Subject: [PATCH 2/3] Update tests --- .../airflow/ui/tests/e2e/pages/EventsPage.ts | 240 +++++++++--------- .../ui/tests/e2e/specs/events-page.spec.ts | 128 +++++----- 2 files changed, 190 insertions(+), 178 deletions(-) diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts index 6101c4dcffdda..1944e8a8756c9 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts @@ -23,29 +23,29 @@ import { BasePage } from "tests/e2e/pages/BasePage"; * Events Page Object */ export class EventsPage extends BasePage { - // Page URLs - public static get eventsListUrl(): string { - return "/events"; + public static get eventsListPaginatedURL(): string { + return "events?limit=1&offset=1"; } - public static get eventsListPaginatedURL(): string { - return 'events?limit=1&offset=1' + // Page URLs + public static get eventsListUrl(): string { + return "/events"; } + public readonly dagIdColumn: Locator; public readonly eventsPageTitle: Locator; public readonly eventsTable: Locator; - public readonly filterBar: Locator; - public readonly timestampColumn: Locator; public readonly eventTypeColumn: Locator; - public readonly dagIdColumn: Locator; - public readonly userColumn: Locator; public readonly extraColumn: Locator; - public readonly runIdColumn: Locator; - public readonly taskIdColumn: Locator; + public readonly filterBar: Locator; public readonly mapIndexColumn: Locator; - public readonly tryNumberColumn: Locator; public readonly paginationNextButton: Locator; public readonly paginationPrevButton: Locator; + public readonly runIdColumn: Locator; + public readonly taskIdColumn: Locator; + public readonly timestampColumn: Locator; + public readonly tryNumberColumn: Locator; + public readonly userColumn: Locator; public constructor(page: Page) { super(page); @@ -67,153 +67,152 @@ export class EventsPage extends BasePage { } /** - * Navigate to Events page + * Click on a column header to sort */ - public async navigate(): Promise { - await this.navigateTo(EventsPage.eventsListUrl); - } + public async clickColumnHeader(columnIndex: number): Promise { + const header = this.eventsTable.locator(`thead th:nth-child(${columnIndex + 1})`); - public async navigateToPaginatedEventsPage(): Promise { - await this.navigateTo(EventsPage.eventsListPaginatedURL); + await header.click(); + await this.waitForEventsTable(); } /** - * Wait for events table to be visible + * Click next page button */ - public async waitForEventsTable(): Promise { - await expect(this.eventsTable).toBeVisible({ timeout: 10_000 }); + public async clickNextPage(): Promise { + await expect(this.paginationNextButton).toBeEnabled(); + + // Get first row content before click to detect change + const firstRowBefore = await this.eventsTable.locator("tbody tr").first().textContent(); + + await this.paginationNextButton.click(); + + // Wait for table content to change (indicating pagination happened) + await this.page.waitForFunction( + (beforeContent) => { + const firstRow = document.querySelector("table tbody tr"); + + return firstRow && firstRow.textContent !== beforeContent; + }, + firstRowBefore, + { timeout: 10_000 }, + ); + + await this.waitForEventsTable(); } /** - * Get the number of table rows with data + * Click previous page button */ - public async getTableRowCount(): Promise { + public async clickPrevPage(): Promise { + await this.paginationPrevButton.click(); await this.waitForEventsTable(); - return await this.eventsTable.locator("tbody tr").count(); } /** * Get text content from a specific table cell */ public async getCellContent(rowIndex: number, columnIndex: number): Promise { - const cell = this.eventsTable.locator(`tbody tr:nth-child(${rowIndex + 1}) td:nth-child(${columnIndex + 1})`); + const cell = this.eventsTable.locator( + `tbody tr:nth-child(${rowIndex + 1}) td:nth-child(${columnIndex + 1})`, + ); + await expect(cell).toBeVisible(); - + // Try multiple methods to get cell content, as WebKit might behave differently - let content = await cell.textContent(); - - if (!content || content.trim() === "") { - // Fallback: try innerText - content = await cell.innerText().catch(() => ""); - } - - if (!content || content.trim() === "") { + let content = (await cell.textContent()) ?? ""; + + if (content.trim() === "") { // Fallback: try getting content from child elements - const childContent = await cell.locator("*").first().textContent().catch(() => ""); - content = childContent; + const childContent = await cell + .locator("*") + .first() + .textContent() + .catch(() => ""); + + content = childContent ?? ""; } - - return content || ""; + + return content; } - + + /** + * Get sort indicator from column header + */ + public async getColumnSortIndicator(columnIndex: number): Promise { + const header = this.eventsTable.locator(`thead th:nth-child(${columnIndex + 1})`); + + // Check for SVG elements with sort-related aria-label + const sortSvg = header.locator('svg[aria-label*="sorted"]'); + const svgCount = await sortSvg.count(); + + if (svgCount && svgCount > 0) { + const svgAriaLabel = (await sortSvg.first().getAttribute("aria-label")) ?? ""; + + if (svgAriaLabel) { + if (svgAriaLabel.includes("ascending")) { + return "ascending"; + } + if (svgAriaLabel.includes("descending")) { + return "descending"; + } + } + } + + return "none"; + } + + /** + * Get the number of table rows with data + */ + public async getTableRowCount(): Promise { + await this.waitForEventsTable(); + + return await this.eventsTable.locator("tbody tr").count(); + } + + /** + * Navigate to Events page + */ + public async navigate(): Promise { + await this.navigateTo(EventsPage.eventsListUrl); + } + + public async navigateToPaginatedEventsPage(): Promise { + await this.navigateTo(EventsPage.eventsListPaginatedURL); + } + /** * Verify that log entries contain expected data patterns */ public async verifyLogEntriesWithData(): Promise { const rowCount = await this.getTableRowCount(); + expect(rowCount).toBeGreaterThan(1); - + // Check event column (second column) - should contain event type const eventText = await this.getCellContent(0, 1); + expect(eventText.trim()).not.toBe(""); - + // Check user column (third column) - should contain user info const userText = await this.getCellContent(0, 2); + expect(userText.trim()).not.toBe(""); } - public async waitForTimeout(timeoutMs: number = 2000): Promise { - await this.page.waitForTimeout(timeoutMs); - } - - /** - * Click next page button - */ - public async clickNextPage(): Promise { - await expect(this.paginationNextButton).toBeEnabled(); - - // Get first row content before click to detect change - const firstRowBefore = await this.eventsTable.locator("tbody tr").first().textContent(); - - await this.paginationNextButton.click(); - - // Wait for table content to change (indicating pagination happened) - await this.page.waitForFunction( - (beforeContent) => { - const firstRow = document.querySelector('table tbody tr'); - return firstRow && firstRow.textContent !== beforeContent; - }, - firstRowBefore, - { timeout: 10000 } - ); - - await this.waitForEventsTable(); - } - - /** - * Click previous page button - */ - public async clickPrevPage(): Promise { - await this.paginationPrevButton.click(); - await this.waitForEventsTable(); - } - - /** - * Click on a column header to sort - */ - public async clickColumnHeader(columnIndex: number): Promise { - const header = this.eventsTable.locator(`thead th:nth-child(${columnIndex + 1})`); - await header.click(); - await this.waitForEventsTable(); - } - - /** - * Get sort indicator from column header - */ - public async getColumnSortIndicator(columnIndex: number): Promise { - const header = this.eventsTable.locator(`thead th:nth-child(${columnIndex + 1})`); - - // Check for SVG elements with sort-related aria-label - const sortSvg = header.locator('svg[aria-label*="sorted"]'); - const svgCount = await sortSvg.count(); - - if (svgCount > 0) { - const svgAriaLabel = await sortSvg.first().getAttribute('aria-label'); - - if (svgAriaLabel) { - if (svgAriaLabel.includes('ascending')) { - return 'ascending'; - } - if (svgAriaLabel.includes('descending')) { - return 'descending'; - } - } - } - - return 'none'; - } - /** * Verify that required table columns are visible */ public async verifyTableColumns(): Promise { // Wait for table headers to be present await expect(this.eventsTable.locator("thead")).toBeVisible(); - + // Verify we have table headers (at least 4 core columns) const headerCount = await this.eventsTable.locator("thead th").count(); + expect(headerCount).toBeGreaterThanOrEqual(4); - + // Verify the first few essential columns are present await expect(this.timestampColumn).toBeVisible(); await expect(this.eventTypeColumn).toBeVisible(); @@ -225,4 +224,15 @@ export class EventsPage extends BasePage { await expect(this.mapIndexColumn).toBeVisible(); await expect(this.tryNumberColumn).toBeVisible(); } -} \ No newline at end of file + + /** + * Wait for events table to be visible + */ + public async waitForEventsTable(): Promise { + await expect(this.eventsTable).toBeVisible({ timeout: 10_000 }); + } + + public async waitForTimeout(timeoutMs: number = 2000): Promise { + await this.page.waitForTimeout(timeoutMs); + } +} diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts index 138901d8deb02..6fb825248d828 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts @@ -18,8 +18,8 @@ */ import { expect, test } from "@playwright/test"; import { testConfig } from "playwright.config"; -import { EventsPage } from "tests/e2e/pages/EventsPage"; import { DagsPage } from "tests/e2e/pages/DagsPage"; +import { EventsPage } from "tests/e2e/pages/EventsPage"; test.describe("Events Page", () => { let eventsPage: EventsPage; @@ -31,45 +31,51 @@ test.describe("Events Page", () => { test("should verify search input is visible", async () => { await eventsPage.navigate(); await eventsPage.waitForEventsTable(); - + // Verify filter bar (containing search functionality) is visible await expect(eventsPage.filterBar).toBeVisible({ timeout: 10_000 }); - + // Verify the filter button is present (allows adding search filters) const filterButton = eventsPage.page.locator('button:has-text("Filter")'); + await expect(filterButton).toBeVisible(); - + // Click the filter button to open the filter menu await filterButton.click(); - + // Verify filter menu opened - be more specific to target the filter menu const filterMenu = eventsPage.page.locator('[role="menu"][aria-labelledby*="menu"][data-state="open"]'); - await expect(filterMenu).toBeVisible({ timeout: 5_000 }); - + + await expect(filterMenu).toBeVisible({ timeout: 5000 }); + // Look for text search options in the menu - const textSearchOptions = eventsPage.page.locator('[role="menuitem"]:has-text("DAG ID"), [role="menuitem"]:has-text("Event Type"), [role="menuitem"]:has-text("User")'); - await textSearchOptions.count() > 0; + const textSearchOptions = eventsPage.page.locator( + '[role="menuitem"]:has-text("DAG ID"), [role="menuitem"]:has-text("Event Type"), [role="menuitem"]:has-text("User")', + ); + + const textSearchOptionsCount = await textSearchOptions.count(); + + expect(textSearchOptionsCount).toBeGreaterThan(0); await expect(textSearchOptions.first()).toBeVisible(); - + // Close the menu by pressing Escape - await eventsPage.page.keyboard.press('Escape'); - + await eventsPage.page.keyboard.press("Escape"); + // Wait a moment for menu to close await eventsPage.page.waitForTimeout(500); - }); test("should verify events page", async () => { await eventsPage.navigate(); - await expect(async () => { // To avoid flakiness, we use a promise to wait for the elements to be visible + await expect(async () => { + // To avoid flakiness, we use a promise to wait for the elements to be visible await expect(eventsPage.eventsPageTitle).toBeVisible({ timeout: 10_000 }); await eventsPage.waitForEventsTable(); await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); await eventsPage.verifyTableColumns(); }).toPass({ timeout: 30_000 }); }); - }); test.describe("Events with Generated Data", () => { @@ -87,58 +93,54 @@ test.describe("Events with Generated Data", () => { // First, trigger a DAG to generate audit log entries await dagsPage.triggerDag(testDagId); - - // Navigate to events page - await eventsPage.navigate(); - - // Wait for table to load + await expect(async () => { + // Navigate to events page + await eventsPage.navigate(); + // Wait for table to load + await eventsPage.waitForEventsTable(); + // Verify the log entries contain actual data + await eventsPage.verifyLogEntriesWithData(); + }).toPass({ timeout: 30_000 }); + }); + + test("should verify pagination works with small page size", async () => { + // Navigate to events page with small page size to force pagination + await eventsPage.navigateToPaginatedEventsPage(); + await eventsPage.waitForEventsTable(); - - // Wait for audit log entries to appear (they may take time to load within table) - await eventsPage.waitForTimeout(5000); - - // Verify the log entries contain actual data - await eventsPage.verifyLogEntriesWithData(); - - }); - - test("should verify pagination works with small page size", async () => { - // Navigate to events page with small page size to force pagination - await eventsPage.navigateToPaginatedEventsPage(); - - await eventsPage.waitForEventsTable(); - - await expect(eventsPage.paginationNextButton).toBeVisible({ timeout: 10_000 }); - await expect(eventsPage.paginationPrevButton).toBeVisible({ timeout: 10_000 }); - - // Test pagination functionality - should have enough data to enable next button - await expect(eventsPage.paginationNextButton).toBeEnabled({ timeout: 10_000 }); - - // Click next page - content should change - await eventsPage.clickNextPage(); - await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); - + + await expect(eventsPage.paginationNextButton).toBeVisible({ timeout: 10_000 }); + await expect(eventsPage.paginationPrevButton).toBeVisible({ timeout: 10_000 }); + + // Test pagination functionality - should have enough data to enable next button + await expect(eventsPage.paginationNextButton).toBeEnabled({ timeout: 10_000 }); + + // Click next page - content should change + await eventsPage.clickNextPage(); + await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + // Click prev page - content should change and previous button should be enabled₹ - await expect(eventsPage.paginationPrevButton).toBeEnabled({ timeout: 5_000 }); - - await eventsPage.clickPrevPage(); - await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); - }); + await expect(eventsPage.paginationPrevButton).toBeEnabled({ timeout: 5000 }); - test("should verify column sorting works", async () => { - await eventsPage.navigate(); - await eventsPage.waitForEventsTable(); + await eventsPage.clickPrevPage(); + await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + }); - await eventsPage.waitForTimeout(5000); + test("should verify column sorting works", async () => { + await eventsPage.navigate(); + await eventsPage.waitForEventsTable(); - // Click first column header (Timestamp) to sort - await eventsPage.clickColumnHeader(0); - await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + await eventsPage.waitForTimeout(5000); - await eventsPage.waitForTimeout(5000); + // Click first column header (Timestamp) to sort + await eventsPage.clickColumnHeader(0); + await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); - // Verify sort indicator shows ascending - const sortIndicator = await eventsPage.getColumnSortIndicator(0); - expect(sortIndicator).not.toBe("none"); - }); -}); \ No newline at end of file + await eventsPage.waitForTimeout(5000); + + // Verify sort indicator shows ascending + const sortIndicator = await eventsPage.getColumnSortIndicator(0); + + expect(sortIndicator).not.toBe("none"); + }); +}); From b76c45441394075a29243775ee5e49b7ac41b1db Mon Sep 17 00:00:00 2001 From: Prajwal7842 Date: Thu, 8 Jan 2026 19:00:23 +0530 Subject: [PATCH 3/3] Update tests --- .../airflow/ui/tests/e2e/pages/EventsPage.ts | 69 ++++++++----- .../ui/tests/e2e/specs/events-page.spec.ts | 99 ++++++++++++++----- 2 files changed, 118 insertions(+), 50 deletions(-) diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts index 1944e8a8756c9..49a6440f31e75 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts @@ -24,7 +24,7 @@ import { BasePage } from "tests/e2e/pages/BasePage"; */ export class EventsPage extends BasePage { public static get eventsListPaginatedURL(): string { - return "events?limit=1&offset=1"; + return "events?limit=5&offset=1"; } // Page URLs @@ -52,25 +52,24 @@ export class EventsPage extends BasePage { this.eventsPageTitle = page.locator('h2:has-text("Audit Log")'); this.eventsTable = page.getByRole("table"); this.filterBar = page.locator('div:has(button:has-text("Filter"))').first(); - // Use table header selectors - will check for presence of key columns - this.timestampColumn = page.locator("thead th").first(); - this.eventTypeColumn = page.locator("thead th").nth(1); - this.userColumn = page.locator("thead th").nth(2); - this.extraColumn = page.locator("thead th").nth(3); - this.dagIdColumn = page.locator("thead th").nth(4); - this.runIdColumn = page.locator("thead th").nth(5); - this.taskIdColumn = page.locator("thead th").nth(6); - this.mapIndexColumn = page.locator("thead th").nth(7); - this.tryNumberColumn = page.locator("thead th").nth(8); + this.timestampColumn = page.getByTestId("table-list").locator('th:has-text("when")'); + this.eventTypeColumn = page.getByTestId("table-list").locator('th:has-text("event")'); + this.userColumn = page.getByTestId("table-list").locator('th:has-text("user")'); + this.extraColumn = page.getByTestId("table-list").locator('th:has-text("extra")'); + this.dagIdColumn = page.getByTestId("table-list").locator('th:has-text("dag id")'); + this.runIdColumn = page.getByTestId("table-list").locator('th:has-text("run id")'); + this.taskIdColumn = page.getByTestId("table-list").locator('th:has-text("task id")'); + this.mapIndexColumn = page.getByTestId("table-list").locator('th:has-text("map index")'); + this.tryNumberColumn = page.getByTestId("table-list").locator('th:has-text("try number")'); this.paginationNextButton = page.locator('[data-testid="next"]'); this.paginationPrevButton = page.locator('[data-testid="prev"]'); } /** - * Click on a column header to sort + * Click on a column header to sort by column name */ - public async clickColumnHeader(columnIndex: number): Promise { - const header = this.eventsTable.locator(`thead th:nth-child(${columnIndex + 1})`); + public async clickColumnHeader(columnName: string): Promise { + const header = this.getColumnLocator(columnName); await header.click(); await this.waitForEventsTable(); @@ -119,7 +118,6 @@ export class EventsPage extends BasePage { await expect(cell).toBeVisible(); - // Try multiple methods to get cell content, as WebKit might behave differently let content = (await cell.textContent()) ?? ""; if (content.trim() === "") { @@ -137,23 +135,23 @@ export class EventsPage extends BasePage { } /** - * Get sort indicator from column header + * Get sort indicator from column header by column name */ - public async getColumnSortIndicator(columnIndex: number): Promise { - const header = this.eventsTable.locator(`thead th:nth-child(${columnIndex + 1})`); + public async getColumnSortIndicator(columnName: string): Promise { + const header = this.getColumnLocator(columnName); // Check for SVG elements with sort-related aria-label const sortSvg = header.locator('svg[aria-label*="sorted"]'); const svgCount = await sortSvg.count(); - if (svgCount && svgCount > 0) { - const svgAriaLabel = (await sortSvg.first().getAttribute("aria-label")) ?? ""; + if (svgCount > 0) { + const ariaLabel = (await sortSvg.first().getAttribute("aria-label")) ?? ""; - if (svgAriaLabel) { - if (svgAriaLabel.includes("ascending")) { + if (ariaLabel) { + if (ariaLabel.includes("sorted-ascending") || ariaLabel.includes("ascending")) { return "ascending"; } - if (svgAriaLabel.includes("descending")) { + if (ariaLabel.includes("sorted-descending") || ariaLabel.includes("descending")) { return "descending"; } } @@ -235,4 +233,29 @@ export class EventsPage extends BasePage { public async waitForTimeout(timeoutMs: number = 2000): Promise { await this.page.waitForTimeout(timeoutMs); } + + /** + * Get column locator by column name + */ + private getColumnLocator(columnName: string): Locator { + const columnMap: Record = { + "dag id": this.dagIdColumn, + event: this.eventTypeColumn, + extra: this.extraColumn, + "map index": this.mapIndexColumn, + "run id": this.runIdColumn, + "task id": this.taskIdColumn, + "try number": this.tryNumberColumn, + user: this.userColumn, + when: this.timestampColumn, + }; + + const header = columnMap[columnName.toLowerCase()]; + + if (!header) { + throw new Error(`Column "${columnName}" not found in column map`); + } + + return header; + } } diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts b/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts index 6fb825248d828..956bd9bfcf4d0 100644 --- a/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts @@ -17,7 +17,7 @@ * under the License. */ import { expect, test } from "@playwright/test"; -import { testConfig } from "playwright.config"; +import { testConfig, AUTH_FILE } from "playwright.config"; import { DagsPage } from "tests/e2e/pages/DagsPage"; import { EventsPage } from "tests/e2e/pages/EventsPage"; @@ -28,7 +28,7 @@ test.describe("Events Page", () => { eventsPage = new EventsPage(page); }); - test("should verify search input is visible", async () => { + test("verify search input is visible", async () => { await eventsPage.navigate(); await eventsPage.waitForEventsTable(); @@ -60,19 +60,16 @@ test.describe("Events Page", () => { // Close the menu by pressing Escape await eventsPage.page.keyboard.press("Escape"); - - // Wait a moment for menu to close - await eventsPage.page.waitForTimeout(500); }); - test("should verify events page", async () => { + test("verify events page", async () => { await eventsPage.navigate(); await expect(async () => { // To avoid flakiness, we use a promise to wait for the elements to be visible - await expect(eventsPage.eventsPageTitle).toBeVisible({ timeout: 10_000 }); + await expect(eventsPage.eventsPageTitle).toBeVisible(); await eventsPage.waitForEventsTable(); - await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + await expect(eventsPage.eventsTable).toBeVisible(); await eventsPage.verifyTableColumns(); }).toPass({ timeout: 30_000 }); }); @@ -80,22 +77,28 @@ test.describe("Events Page", () => { test.describe("Events with Generated Data", () => { let eventsPage: EventsPage; - let dagsPage: DagsPage; const testDagId = testConfig.testDag.id; + test.beforeAll(async ({ browser }) => { + test.setTimeout(5 * 60 * 1000); // 5 minutes timeout + + // First, trigger a DAG to generate audit log entries + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + const dagsPage = new DagsPage(page); + + await dagsPage.triggerDag(testDagId); + await context.close(); + }); + test.beforeEach(({ page }) => { eventsPage = new EventsPage(page); - dagsPage = new DagsPage(page); }); - test("should show audit log entries after triggering a DAG", async () => { - test.setTimeout(3 * 60 * 1000); // 3 minutes timeout + test("verify audit log entries are shown correctly", async () => { + await eventsPage.navigate(); - // First, trigger a DAG to generate audit log entries - await dagsPage.triggerDag(testDagId); await expect(async () => { - // Navigate to events page - await eventsPage.navigate(); // Wait for table to load await eventsPage.waitForEventsTable(); // Verify the log entries contain actual data @@ -103,7 +106,7 @@ test.describe("Events with Generated Data", () => { }).toPass({ timeout: 30_000 }); }); - test("should verify pagination works with small page size", async () => { + test("verify pagination works with small page size", async () => { // Navigate to events page with small page size to force pagination await eventsPage.navigateToPaginatedEventsPage(); @@ -126,21 +129,63 @@ test.describe("Events with Generated Data", () => { await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); }); - test("should verify column sorting works", async () => { + test("verify column sorting works", async () => { await eventsPage.navigate(); - await eventsPage.waitForEventsTable(); + await expect(async () => { + await eventsPage.waitForEventsTable(); + await eventsPage.verifyLogEntriesWithData(); + }).toPass({ timeout: 20_000 }); - await eventsPage.waitForTimeout(5000); + // Get initial timestamps before sorting (first 3 rows) + const initialTimestamps = []; + const rowCount = await eventsPage.getTableRowCount(); - // Click first column header (Timestamp) to sort - await eventsPage.clickColumnHeader(0); - await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + for (let i = 0; i < rowCount; i++) { + const timestamp = await eventsPage.getCellContent(i, 0); + + initialTimestamps.push(timestamp); + } + + // Click timestamp column header until we get ascending sort. + let maxIterations = 5; + + while (maxIterations > 0) { + await expect(eventsPage.eventsTable).toBeVisible({ timeout: 5000 }); + const sortIndicator = await eventsPage.getColumnSortIndicator("when"); + + if (sortIndicator !== "none") { + break; + } + await eventsPage.clickColumnHeader("when"); + maxIterations--; + } + + await expect(async () => { + await eventsPage.waitForEventsTable(); + await eventsPage.verifyLogEntriesWithData(); + }).toPass({ timeout: 20_000 }); + + // Get timestamps after sorting + const sortedTimestamps = []; + + for (let i = 0; i < rowCount; i++) { + const timestamp = await eventsPage.getCellContent(i, 0); - await eventsPage.waitForTimeout(5000); + sortedTimestamps.push(timestamp); + } - // Verify sort indicator shows ascending - const sortIndicator = await eventsPage.getColumnSortIndicator(0); + let initialSortedTimestamps = [...initialTimestamps].sort( + (a, b) => new Date(a).getTime() - new Date(b).getTime(), + ); + // Verify that the order matches either ascending or descending sorted timestamps + const initialSortedDescending = [...initialSortedTimestamps].reverse(); + const isAscending = sortedTimestamps.every( + (timestamp, index) => timestamp === initialSortedTimestamps[index], + ); + const isDescending = sortedTimestamps.every( + (timestamp, index) => timestamp === initialSortedDescending[index], + ); - expect(sortIndicator).not.toBe("none"); + expect(isAscending || isDescending).toBe(true); }); });