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..49a6440f31e75 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/pages/EventsPage.ts @@ -0,0 +1,261 @@ +/*! + * 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 { + public static get eventsListPaginatedURL(): string { + return "events?limit=5&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 eventTypeColumn: Locator; + public readonly extraColumn: Locator; + public readonly filterBar: Locator; + public readonly mapIndexColumn: 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); + 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(); + 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 by column name + */ + public async clickColumnHeader(columnName: string): Promise { + const header = this.getColumnLocator(columnName); + + await header.click(); + await this.waitForEventsTable(); + } + + /** + * 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: 10_000 }, + ); + + await this.waitForEventsTable(); + } + + /** + * Click previous page button + */ + public async clickPrevPage(): Promise { + await this.paginationPrevButton.click(); + await this.waitForEventsTable(); + } + + /** + * 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(); + + 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 ?? ""; + } + + return content; + } + + /** + * Get sort indicator from column header by column name + */ + 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 > 0) { + const ariaLabel = (await sortSvg.first().getAttribute("aria-label")) ?? ""; + + if (ariaLabel) { + if (ariaLabel.includes("sorted-ascending") || ariaLabel.includes("ascending")) { + return "ascending"; + } + if (ariaLabel.includes("sorted-descending") || ariaLabel.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(""); + } + + /** + * 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(); + } + + /** + * 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); + } + + /** + * 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 new file mode 100644 index 0000000000000..956bd9bfcf4d0 --- /dev/null +++ b/airflow-core/src/airflow/ui/tests/e2e/specs/events-page.spec.ts @@ -0,0 +1,191 @@ +/*! + * 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 { DagsPage } from "tests/e2e/pages/DagsPage"; +import { EventsPage } from "tests/e2e/pages/EventsPage"; + +test.describe("Events Page", () => { + let eventsPage: EventsPage; + + test.beforeEach(({ page }) => { + eventsPage = new EventsPage(page); + }); + + test("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: 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")', + ); + + 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"); + }); + + 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(); + await eventsPage.waitForEventsTable(); + await expect(eventsPage.eventsTable).toBeVisible(); + await eventsPage.verifyTableColumns(); + }).toPass({ timeout: 30_000 }); + }); +}); + +test.describe("Events with Generated Data", () => { + let eventsPage: EventsPage; + 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); + }); + + test("verify audit log entries are shown correctly", async () => { + await eventsPage.navigate(); + + await expect(async () => { + // Wait for table to load + await eventsPage.waitForEventsTable(); + // Verify the log entries contain actual data + await eventsPage.verifyLogEntriesWithData(); + }).toPass({ timeout: 30_000 }); + }); + + test("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: 5000 }); + + await eventsPage.clickPrevPage(); + await expect(eventsPage.eventsTable).toBeVisible({ timeout: 10_000 }); + }); + + test("verify column sorting works", async () => { + await eventsPage.navigate(); + await expect(async () => { + await eventsPage.waitForEventsTable(); + await eventsPage.verifyLogEntriesWithData(); + }).toPass({ timeout: 20_000 }); + + // Get initial timestamps before sorting (first 3 rows) + const initialTimestamps = []; + const rowCount = await eventsPage.getTableRowCount(); + + 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); + + sortedTimestamps.push(timestamp); + } + + 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(isAscending || isDescending).toBe(true); + }); +});