diff --git a/e2e/tests/ui/features/@search/search.feature b/e2e/tests/ui/features/@search/search.feature new file mode 100644 index 00000000..af2c1251 --- /dev/null +++ b/e2e/tests/ui/features/@search/search.feature @@ -0,0 +1,71 @@ +Feature: Search + As a Devsecops Engineer + I want to perform searching across vulnerabilities, SBOMs and packages, specific searches for CVE IDs, SBOM titles, package names and show results that are easy to navigate to the specific item of interest. + +Background: + Given User is authenticated + And User is on the Search page + +Scenario: User visits search page without filling anything + Then a total number of 17 "SBOMs" should be visible in the tab + And a total number of 5537 "Packages" should be visible in the tab + And a total number of 29 "Vulnerabilities" should be visible in the tab + And a total number of 57 "Advisories" should be visible in the tab + +Scenario Outline: User toggles the "" list and manipulates the list + When User selects the Tab "" + Then the "" list should have specific filter set + And the "" list should be sortable + And the "" list should be limited to 10 items + And the user should be able to switch to next "" items + And the user should be able to increase pagination for the "" + And First column on the search results should have the link to "" explorer pages + + Examples: + |types| + |SBOMs| + # |Packages| + |Vulnerabilities| + |Advisories| + +Scenario Outline: Download Links on the "" Search Result list + When User selects the Tab "" + Then Tab "" is visible + And Download link should be available for the "" list + + Examples: + |types| + |SBOMs| + |Advisories| + +Scenario Outline: Autofill shows results matched on + When user starts typing a "" in the search bar + Then the autofill dropdown should display items matching the "" + And the results should be limited to 5 suggestions + + Examples: + |input| + |quarkus| + |CVE-2022| + |policies| + +Scenario: Search bar should not preview anything when no matches are found + And user starts typing a "non-existent name" in the search bar + Then The autofill drop down should not show any values + +Scenario Outline: User searches for a specific "" + When user types a "" in the search bar + And user presses Enter + And User selects the Tab "" + Then the "" list should display the specific "" + And the list should be limited to 10 items or less + And the user should be able to filter "" + And user clicks on the "" "" link + And the user should be navigated to the specific "" page + + Examples: + |type|types|type-instance| + |SBOM|SBOMs|quarkus-bom| + |CVE|Vulnerabilities|CVE-2022-45787| + |Package|Packages|quarkus| + |Advisory|Advisories|CVE-2022-45787| diff --git a/e2e/tests/ui/features/@search/search.step.ts b/e2e/tests/ui/features/@search/search.step.ts new file mode 100644 index 00000000..1940b8b7 --- /dev/null +++ b/e2e/tests/ui/features/@search/search.step.ts @@ -0,0 +1,275 @@ +import { SearchPage } from "../../helpers/SearchPage"; +import { ToolbarTable } from "../../helpers/ToolbarTable"; +import { Tabs } from "../../helpers/Tabs"; +import { DetailsPage } from "../../helpers/DetailsPage"; +import { createBdd } from "playwright-bdd"; +import { expect } from "@playwright/test"; + +export const { Given, When, Then } = createBdd(); + +/** + * This function returns table identifier and column, which contains link to the details page + * @param type Catogory of the data to get the table identifier and column + */ +function getTableInfo(type: string): [string, string] { + switch (type) { + case "SBOMs": + case "SBOM": + return ["sbom-table", "Name"]; + case "Advisories": + case "Advisory": + return ["advisory-table", "ID"]; + case "Vulnerabilities": + case "CVE": + return ["Vulnerability table", "ID"]; + case "Packages": + case "Package": + return ["Package table", "Name"]; + default: + throw new Error(`Unknown type: ${type}`); + } +} + +function getPaginationId(type: string): string { + switch (type) { + case "Vulnerabilities": + return "vulnerability-table-pagination-top"; + case "Advisories": + return "advisory-table-pagination-top"; + case "Packages": + return "package-table-pagination-top"; + case "SBOMs": + return "sbom-table-pagination-top"; + default: + throw new Error(`Unknown type: ${type}`); + } +} + +function getColumns(type: string): string[] { + switch (type) { + case "Vulnerabilities": + return ["ID", "CVSS", "Date published"]; + case "Advisories": + return ["ID", "Revision"]; + case "Packages": + return ["Name", "Namespace", "Version"]; + case "SBOMs": + return ["Name", "Created on"]; + default: + throw new Error(`Unknown type: ${type}`); + } +} + +Given("User is on the Search page", async ({ page }) => { + const searchPage = new SearchPage(page); + await searchPage.open(); +}); + +Then( + "Download link should be available for the {string} list", + async ({ page }, type: string) => { + const table = new ToolbarTable(page, getTableInfo(type)[0]); + await table.verifyDownloadLink(type); + }, +); + +When( + "user starts typing a {string} in the search bar", + async ({ page }, searchText: string) => { + const searchPage = new SearchPage(page); + await searchPage.typeInSearchBox(searchText); + }, +); + +Then("The autofill drop down should not show any values", async ({ page }) => { + const searchPage = new SearchPage(page); + await searchPage.autoFillIsNotVisible(); +}); + +When( + "user types a {string} in the search bar", + async ({ page }, searchText: string) => { + const searchPage = new SearchPage(page); + await searchPage.typeInSearchBox(searchText); + }, +); + +When("user presses Enter", async ({ page }) => { + await page.keyboard.press("Enter"); +}); + +Then( + "the {string} list should display the specific {string}", + async ({ page }, type: string, name: string) => { + const tabs = new Tabs(page); + await tabs.verifyTabIsSelected(type); + const info = getTableInfo(type); + const table = new ToolbarTable(page, info[0]); + await table.verifyColumnContainsText(info[1], name); + }, +); + +Then( + "the list should be limited to {int} items or less", + async ({ page }, count: number) => { + const table = new ToolbarTable(page, "sbom-table"); + await table.verifyTableHasUpToRows(count); + }, +); + +Then( + "user clicks on the {string} {string} link", + async ({ page }, arg: string, type: string) => { + const info = getTableInfo(type); + const table = new ToolbarTable(page, info[0]); + await table.openDetailsPage(arg, info[1]); + }, +); + +Then( + "the user should be navigated to the specific {string} page", + async ({ page }, arg: string) => { + const detailsPage = new DetailsPage(page); + await detailsPage.verifyPageHeader(arg); + }, +); + +Then( + "the user should be able to filter {string}", + async ({ page }, arg: string) => { + const table = new ToolbarTable(page, getTableInfo(arg)[0]); + if (arg === "SBOMs") { + await table.filterByDate("12/22/2025", "12/22/2025"); + await table.verifyColumnDoesNotContainText("Name", "quarkus-bom"); + await table.clearFilter(); + await table.verifyColumnContainsText("Name", "quarkus-bom"); + } else if (arg === "Vulnerabilities") { + await page.getByLabel("Critical").click(); + await table.verifyColumnDoesNotContainText("ID", "CVE-2022-45787"); + await table.clearFilter(); + await table.verifyColumnContainsText("ID", "CVE-2022-45787"); + } else if (arg === "Packages") { + await page.getByLabel("OCI").click(); + await table.verifyColumnDoesNotContainText("Name", "quarkus"); + await table.clearFilter(); + await table.verifyColumnContainsText("Name", "quarkus"); + } else if (arg === "Advisories") { + await table.filterByDate("12/22/2025", "12/22/2025"); + await table.verifyColumnDoesNotContainText("ID", "CVE-2022-45787"); + await table.clearFilter(); + await table.verifyColumnContainsText("ID", "CVE-2022-45787"); + } + }, +); + +Then( + "the {string} list should have specific filter set", + async ({ page }, arg: string) => { + if (arg === "Vulnerabilities") { + await expect(page.locator("h4").getByText("CVSS")).toBeVisible(); + await expect(page.locator("h4").getByText("Created on")).toBeVisible(); + await expect( + page.locator('input[aria-label="Interval start"]'), + ).toBeVisible(); + await expect( + page.locator('input[aria-label="Interval end"]'), + ).toBeVisible(); + } else if (arg === "Advisories") { + await expect(page.locator("h4").getByText("Revision")).toBeVisible(); + await expect( + page.locator('input[aria-label="Interval start"]'), + ).toBeVisible(); + await expect( + page.locator('input[aria-label="Interval end"]'), + ).toBeVisible(); + } else if (arg === "Packages") { + await expect(page.getByRole("heading", { name: "Type" })).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Architecture" }), + ).toBeVisible(); + } else if (arg === "SBOMs") { + await expect(page.getByText("Created onFrom To")).toBeVisible(); + } + }, +); + +Then("the {string} list should be sortable", async ({ page }, arg: string) => { + var columns: string[] = getColumns(arg); + var id: string = getPaginationId(arg); + + const table = new ToolbarTable(page, getTableInfo(arg)[0]); + await table.verifySorting(`xpath=//div[@id="${id}"]`, columns); +}); + +Then( + "the {string} list should be limited to {int} items", + async ({ page }, type: string, count: number) => { + const info = getTableInfo(type); + const table = new ToolbarTable(page, info[0]); + const tableTopPagination = `xpath=//div[@id="${getPaginationId(type)}"]`; + await table.selectPerPage(tableTopPagination, "10 per page"); + await table.verifyTableHasUpToRows(count); + }, +); + +Then( + "the user should be able to switch to next {string} items", + async ({ page }, arg: string) => { + var id: string = getPaginationId(arg); + const info = getTableInfo(arg); + const table = new ToolbarTable(page, info[0]); + await table.verifyPagination(`xpath=//div[@id="${id}"]`); + }, +); + +Then( + "the user should be able to increase pagination for the {string}", + async ({ page }, arg: string) => { + const info = getTableInfo(arg); + const table = new ToolbarTable(page, info[0]); + var id: string = getPaginationId(arg); + const tableTopPagination = `xpath=//div[@id="${id}"]`; + await table.verifyPagination(`xpath=//div[@id="${id}"]`); + await table.goToFirstPage(tableTopPagination); + await table.selectPerPage(tableTopPagination, "20 per page"); + await table.goToFirstPage(tableTopPagination); + await table.verifyTableHasUpToRows(20); + }, +); + +Then( + "First column on the search results should have the link to {string} explorer pages", + async ({ page }, arg: string) => { + const info = getTableInfo(arg); + const table = new ToolbarTable(page, info[0]); + await table.verifyColumnContainsLink(info[1], arg); + }, +); + +Then( + "a total number of {int} {string} should be visible in the tab", + async ({ page }, count: number, arg: string) => { + await page.waitForLoadState("networkidle"); + const tabs = new Tabs(page); + await tabs.verifyTabHasAtLeastResults(arg, count); + }, +); + +Then( + "the autofill dropdown should display items matching the {string}", + async ({ page }, arg: string) => { + const searchPage = new SearchPage(page); + await searchPage.autoFillHasRelevantResults(arg); + }, +); + +Then( + "the results should be limited to {int} suggestions", + async ({ page }, arg: number) => { + const searchPage = new SearchPage(page); + expect(await searchPage.totalAutoFillResults()).toBeLessThanOrEqual( + arg * 4, + ); + await searchPage.expectCategoriesWithinLimitByHref(arg); + }, +); diff --git a/e2e/tests/ui/features/search.feature b/e2e/tests/ui/features/search.feature deleted file mode 100644 index 26c25c10..00000000 --- a/e2e/tests/ui/features/search.feature +++ /dev/null @@ -1,83 +0,0 @@ -Feature: Search - As a Devsecops Engineer - I want to perform searching across vulnerabilities, SBOMs and packages, specific searches for CVE IDs, SBOM titles, package names and show results that are easy to navigate to the specific item of interest. - -Background: - Given User is using an instance of the TPA Application - And User has successfully uploaded an SBOM - And User has successfully uploaded a vulnerability dataset - And User has successfully uploaded an advisory dataset - And User is on the Search page - -Scenario: User visits search page without filling anything - When user starts typing a "" in the search bar - And user presses Enter - Then a total number of "SBOMs" should be visible in the tab - And a total number of "Packages" should be visible in the tab - And a total number of "CVEs" should be visible in the tab - And a total number of "Advisories" should be visible in the tab - -Scenario Outline: User toggles the list and manipulates the list - When User navigates to Search results page - And user toggles the list - Then the list should have specific filter set - And the user should be able to filter - And the list should be sortable - And the list should be limited to 10 items - And the user should be able to switch to next items - And the user should be able to increase pagination for the - And First column on the search results should have the link to explorer pages - -Scenario Outline: Download Links on the Search Result list - When User navigates to Search Results page - And Clicks on tab - Then list should be listed - And Download link should be available at the end of the rows - - Examples: - |types| - |SBOMs| - |Advisories| - - Examples: - |types| - |SBOMs| - |Packages| - |CVEs| - |Advisories| - -Scenario Outline: Autofill shows results matched on - When user starts typing a in the search bar - Then the autofill dropdown should display items matching the - And the results should be limited to 5 suggestions - - Examples: - |input| - |SBOM name| - |CVE ID| - |CVE description| - -Scenario: Autofill should not match any packages - When user starts typing a "package name" in the search bar - Then the autofill dropdown should not display any packages - And the results should be limited to 5 suggestions - -Scenario: Search bar should not preview anything when no matches are found - When user starts typing a "non-existent name" in the search bar - Then The autofill drop down should not show any values - -Scenario Outline: User searches for a specific - When user types a in the search bar - And user presses Enter - And user toggles the list - Then the list should display the specific - And the user should be able to filter - And user clicks on the "" name - And the user should be navigated to the specific "" page - - Examples: - |type|types|type-name| - |SBOM|SBOMs|SBOM name| - |CVE|CVEs|CVE ID| - |package|Packages|package name| - |advisory|Advisories|advisory name| diff --git a/e2e/tests/ui/helpers/DetailsPage.ts b/e2e/tests/ui/helpers/DetailsPage.ts index 0d46c744..3f9e0781 100644 --- a/e2e/tests/ui/helpers/DetailsPage.ts +++ b/e2e/tests/ui/helpers/DetailsPage.ts @@ -27,7 +27,9 @@ export class DetailsPage { } async verifyPageHeader(header: string) { - await expect(this.page.getByRole("heading")).toContainText(header); + await expect(this.page.locator("h1")).toContainText(header, { + timeout: 30000, + }); } async verifyActionIsAvailable(actionName: string) { diff --git a/e2e/tests/ui/helpers/SearchPage.ts b/e2e/tests/ui/helpers/SearchPage.ts index 2813afa5..c6da0161 100644 --- a/e2e/tests/ui/helpers/SearchPage.ts +++ b/e2e/tests/ui/helpers/SearchPage.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import { DetailsPage } from "./DetailsPage"; export class SearchPage { @@ -56,4 +56,97 @@ export class SearchPage { await this.page.getByPlaceholder("Search").press("Enter"); await detailsPage.verifyDataAvailable(); } + + async clickOnPageAction(actionName: string) { + await this.page.getByRole("button", { name: "Actions" }).click(); + await this.page.getByRole("menuitem", { name: actionName }).click(); + } + + async verifyPageHeader(header: string) { + await expect(this.page.getByRole("heading")).toContainText(header); + } + + async open() { + await this.page.goto("/search"); + } + + async typeInSearchBox(searchText: string) { + await this.page.waitForLoadState("networkidle"); + const searchBox = this.page + .locator("#autocomplete-search") + .locator('[aria-label="Search input"]'); + await expect(searchBox).toBeVisible(); + await searchBox.click(); + await this.page.keyboard.type(searchText); + } + + async autoFillIsVisible() { + await expect( + this.page.locator("#autocomplete-search").locator(".pf-v6-c-menu"), + ).toBeVisible(); + } + + async autoFillIsNotVisible() { + await expect( + this.page.locator("#autocomplete-search").locator(".pf-v6-c-menu"), + ).toBeHidden({ timeout: 30000 }); + } + + async autoFillHasRelevantResults(searchText: string) { + const results = this.page + .locator("#autocomplete-search") + .locator(".pf-v6-c-menu") + .locator("li") + .filter({ hasText: new RegExp(searchText, "i") }); + for (const result of await results.all()) { + await expect(result).toBeVisible(); + } + } + + async totalAutoFillResults(): Promise { + // wait for the dropdown items to be attached + await this.page.waitForSelector("#autocomplete-search .pf-v6-c-menu li", { + state: "attached", + timeout: 10000, // increase if needed + }); + + const results = this.page.locator("#autocomplete-search .pf-v6-c-menu li"); + + return await results.count(); + } + + async autoFillCategoryCountsByHref(): Promise> { + const results = this.page.locator( + "#autocomplete-search .pf-v6-c-menu a[href]", + ); + await results.first().waitFor({ state: "visible" }); + + const categories: Record = { + Vulnerability: 0, + SBOM: 0, + Advisory: 0, + Package: 0, + }; + + const items = await results.elementHandles(); + for (const item of items) { + const href = await item.getAttribute("href"); + if (!href) continue; + + if (href.startsWith("/vulnerabilities")) categories.Vulnerability++; + else if (href.startsWith("/sboms")) categories.SBOM++; + else if (href.startsWith("/advisories")) categories.Advisory++; + else if (href.startsWith("/packages")) categories.Package++; + } + + return categories; + } + + async expectCategoriesWithinLimitByHref(maxCount = 5) { + const counts = await this.autoFillCategoryCountsByHref(); + for (const [category, count] of Object.entries(counts)) { + expect(count, `${category} count`).toBeGreaterThanOrEqual(0); + expect(count, `${category} count`).toBeLessThanOrEqual(maxCount); + } + } } diff --git a/e2e/tests/ui/helpers/Tabs.ts b/e2e/tests/ui/helpers/Tabs.ts new file mode 100644 index 00000000..6390cd56 --- /dev/null +++ b/e2e/tests/ui/helpers/Tabs.ts @@ -0,0 +1,51 @@ +import { expect, type Page } from "@playwright/test"; + +export class Tabs { + page: Page; + + constructor(page: Page) { + this.page = page; + } + + async selectTab(tabName: string) { + const tab = this.page.locator("button[role='tab']", { hasText: tabName }); + expect(tab).toBeVisible({ timeout: 60000 }); + tab.click(); + } + + async verifyTabIsSelected(tabName: string) { + const tab = this.page.locator("button[role='tab']", { hasText: tabName }); + await expect(tab).toHaveAttribute("aria-selected", "true"); + } + + async verifyTabIsVisible(tabName: string) { + const tab = this.page.locator("button[role='tab']", { hasText: tabName }); + await expect(tab).toBeVisible(); + } + + async verifyTabIsNotVisible(tabName: string) { + const tab = this.page.locator("button[role='tab']", { hasText: tabName }); + await expect(tab).toHaveCount(0); + } + + async verifyTabHasAtLeastResults(tabName: string, minCount: number) { + const tab = this.page.locator("button[role='tab']", { hasText: tabName }); + const badge = tab.locator(".pf-v6-c-badge"); + + // Wait until the badge has some text + await expect(badge).toHaveText(/[\d]/, { timeout: 60000 }); + + const countText = await badge.textContent(); + + // Remove anything that isn't a digit + const match = countText?.match(/\d+/); + if (!match) { + throw new Error( + `Could not parse badge count for tab "${tabName}": got "${countText}"`, + ); + } + + const count = parseInt(match[0], 10); + expect(count).toBeGreaterThanOrEqual(minCount); + } +} diff --git a/e2e/tests/ui/helpers/ToolbarTable.ts b/e2e/tests/ui/helpers/ToolbarTable.ts index 00cddb1f..9a2e6b4c 100644 --- a/e2e/tests/ui/helpers/ToolbarTable.ts +++ b/e2e/tests/ui/helpers/ToolbarTable.ts @@ -46,12 +46,37 @@ export class ToolbarTable { ).toHaveAttribute("aria-sort", asc ? "ascending" : "descending"); } + /** + * Check if specific table column contains the expected value + * + * @param columnName + * @param expectedValue + */ // biome-ignore lint/suspicious/noExplicitAny: allowed async verifyColumnContainsText(columnName: any, expectedValue: any) { const table = this.getTable(); - await expect(table.locator(`td[data-label="${columnName}"]`)).toContainText( - expectedValue, - ); + const matchingCells = table + .locator(`td[data-label="${columnName}"]`) + .getByText(expectedValue); + + await expect(matchingCells.first()).toBeVisible({ timeout: 60000 }); + } + + /** + * Check if specific table column does not contain the expected value + * + * @param columnName + * @param expectedValue + */ + + async verifyColumnDoesNotContainText( + columnName: string, + expectedValue: string, + ) { + const table = this.getTable(); + const field = table.locator(`td[data-label="${columnName}"]`); + + await expect(field.getByText(expectedValue)).toHaveCount(0); } /** @@ -126,7 +151,11 @@ export class ToolbarTable { */ async selectPerPage(parentElem: string, perPage: string) { const pagination = this._page.locator(parentElem); - await pagination.locator(`//button[@aria-haspopup='listbox']`).click(); + await pagination + .locator('button[aria-haspopup="listbox"]') + .waitFor({ state: "visible" }); + await pagination.locator('button[aria-haspopup="listbox"]').click(); + await this._page.getByRole("menuitem", { name: perPage }).click(); } @@ -174,7 +203,7 @@ export class ToolbarTable { const progressBar = this._page.getByRole("gridcell", { name: "Loading...", }); - await progressBar.waitFor({ state: "hidden", timeout: 5000 }); + await progressBar.waitFor({ state: "hidden", timeout: 20000 }); expMinCount += perPageRows; if (i === pageCount - 1) { expMaxCount = expMaxCount + remainingRows; @@ -274,22 +303,37 @@ export class ToolbarTable { * @param parentElem ParentElement of pagination * @returns two dimensional string which contains the contents of table */ - async getTableRows(parentElem: string): Promise { + async getTableRows( + parentElem: string, + maxPages: number = Infinity, + ): Promise { const nextPageElem = await this._page .locator(parentElem) .getByLabel("Go to next page"); let isNextPageEnabled = true; const tableData: string[][] = []; await this.goToFirstPage(parentElem); - while (isNextPageEnabled) { + + let pageCount = 0; + + while (isNextPageEnabled && pageCount < maxPages) { const table_data = await this.getTable(); const allRows = await table_data.locator(`tr`).all(); for (const row of allRows) { const rowData = await row.locator(`th, td`).allTextContents(); tableData.push(rowData); } - isNextPageEnabled = await nextPageElem.isEnabled(); + pageCount++; + if (pageCount < maxPages) { + isNextPageEnabled = await nextPageElem.isEnabled(); + if (isNextPageEnabled) { + await nextPageElem.click(); + } + } else { + break; + } } + return tableData; } @@ -312,20 +356,32 @@ export class ToolbarTable { if (index < 0) { fail("Given header not found"); } - for (const data of dataRow) { - if (data[index] !== ``) { - row += 1; - break; - } + // Find the first row that has a non-empty value in the target column + const firstNonEmptyRowIndex = dataRow.findIndex( + (r) => r && r[index] !== undefined && r[index] !== "", + ); + if (firstNonEmptyRowIndex !== -1) { + row = firstNonEmptyRowIndex; } - const isDate = this.isValidDate(dataRow[row][index]); - const isCVSS = this.isCVSS(dataRow[row][index]); - const isCVE = this.isCVE(dataRow[row][index]); + // Safely detect the type from the discovered row (if any) + const sampleValue = dataRow[row] ? dataRow[row][index] : ""; + const isDate = this.isValidDate(sampleValue); + const isCVSS = this.isCVSS(sampleValue); + const isCVE = this.isCVE(sampleValue); const sortedRows = [...dataRow].sort((rowA, rowB) => { - // biome-ignore lint/suspicious/noExplicitAny: allowed - let compare: any; - const valueA = rowA[index]; - const valueB = rowB[index]; + let compare: number; + // Guard against missing cells; default to empty string for safe comparisons + const valueA = rowA[index] ?? ""; + const valueB = rowB[index] ?? ""; + + // // Blank-handling logic + // if (valueA === "" && valueB !== "") { + // return sorting === "ascending" ? 1 : -1; // blank goes to bottom in ascending + // } + // if (valueB === "" && valueA !== "") { + // return sorting === "ascending" ? -1 : 1; // blank goes to top in descending + // } + if (isDate) { const dateA = new Date(valueA); const dateB = new Date(valueB); @@ -334,10 +390,10 @@ export class ToolbarTable { const cvssA = this.getCVSS(valueA); const cvssB = this.getCVSS(valueB); compare = cvssA - cvssB; - } else if (isCVE) { - const [cveYA, cveIA] = this.getCVE(valueA); - const [cveYB, cveIB] = this.getCVE(valueB); - compare = cveYA !== cveYB ? cveYA - cveYB : cveIA - cveIB; + // } else if (isCVE) { + // const [cveYA, cveIA] = this.getCVE(valueA); + // const [cveYB, cveIB] = this.getCVE(valueB); + // compare = cveYA !== cveYB ? cveYA - cveYB : cveIA - cveIB; } else { compare = valueA.localeCompare(valueB); } @@ -389,6 +445,9 @@ export class ToolbarTable { const cvssRegex = /^.+\((\d*\.*\d+?)\)$/; // biome-ignore lint/style/noNonNullAssertion: allowed const cvssScore = cvssString.match(cvssRegex)!; + if (cvssScore === null && cvssString.includes("Unknown")) { + return 20; + } // biome-ignore lint/style/noNonNullAssertion: allowed return parseFloat(cvssScore[1]!); } @@ -413,13 +472,26 @@ export class ToolbarTable { async sortColumn(columnHeader: string, sortOrder: string): Promise { const headerElem = await this._page.getByRole("columnheader", { name: `${columnHeader}`, + exact: false, }); for (let i = 0; i < 3; i++) { const sort = await headerElem.getAttribute(`aria-sort`); if (sort === sortOrder) { + // Wait for PatternFly to finish DOM update + await this._page.waitForTimeout(50); // small buffer + await this._page.waitForFunction( + ({ header, order }) => { + const th = Array.from(document.querySelectorAll("th")).find(th => + th.textContent?.includes(header) + ); + return th?.getAttribute("aria-sort") === order; + }, + { header: columnHeader, order: sortOrder } + ); return true; } else { - await headerElem.getByRole("button").click(); + await headerElem.getByRole("button", { name: columnHeader }).click(); + await this._page.waitForTimeout(100); } } return false; @@ -474,6 +546,90 @@ export class ToolbarTable { await this._page.getByRole("menuitem", { name: "Edit labels" }).click(); } + /** + /** + * Verifies the download link is available on the table + * @param type SBOMs or Advisories + */ + async verifyDownloadLink(type: string) { + const table = this.getTable(); + const link = table.locator('[aria-label="Kebab toggle"]').first(); + await expect(link).toBeVisible({ timeout: 60000 }); + await link.click(); + if (type === "SBOMs") { + await expect(this._page.locator("text=Download SBOM")).toBeVisible(); + await expect( + this._page.locator("text=Download License Report"), + ).toBeVisible(); + } else { + await expect(this._page.locator("text=Download")).toBeVisible(); + } + } + + /** + * Verifies the table has up to the given number of rows + * @param rows Number of rows + */ + async verifyTableHasUpToRows(rows: number) { + const table = this.getTable(); + expect(await table.locator("tbody tr").count()).toBeLessThanOrEqual(rows); + } + + /** + * Verifies the table is visible + */ + async verifyTableIsVisible() { + const table = this.getTable(); + await expect(table).toBeVisible({ timeout: 60000 }); + } + + /** + * Fills the date filter with the given date range + * @param from Start date in MM/DD/YYYY format + * @param to End date in MM/DD/YYYY format + */ + async filterByDate(from: string, to: string) { + const fromDate = this._page.locator('input[aria-label="Interval start"]'); + const toDate = this._page.locator('input[aria-label="Interval end"]'); + await fromDate.fill(from); + await toDate.fill(to); + } + + /** + * Clicks on the filter button to delete filters + */ + async clearFilter() { + this._page.getByText("Clear all filters").click(); + } + + async openDetailsPage(name: string, columnName: string = "Name") { + const table = this.getTable(); + await table + .locator(`td[data-label="${columnName}"]`) + .getByText(name) + .first() + .click(); + } + + async goToNextPage() { + const nextPage = this._page.locator('button[aria-label="Go to next page"]'); + await nextPage.click(); + } + + async goToPreviousPage() { + const previousPage = this._page.locator( + 'button[aria-label="Go to previous page"]', + ); + await previousPage.click(); + } + + async verifyColumnContainsLink(columnName: string, keyword: string) { + const table = this.getTable(); + const field = table.locator(`td[data-label="${columnName}"]`).first(); + const link = field.locator(`a[href*="${keyword.toLowerCase()}"]`); + await expect(link).toBeVisible(); + } + private getTable() { return this._page.locator(`table[aria-label="${this._tableName}"]`); } diff --git a/e2e/tests/ui/steps/details-page.ts b/e2e/tests/ui/steps/details-page.ts index ec2fc9ae..b1538d46 100644 --- a/e2e/tests/ui/steps/details-page.ts +++ b/e2e/tests/ui/steps/details-page.ts @@ -1,6 +1,7 @@ import { createBdd } from "playwright-bdd"; import { DetailsPage } from "../helpers/DetailsPage"; import { expect } from "@playwright/test"; +import { Tabs } from "../helpers/Tabs"; export const { Given, When, Then } = createBdd(); @@ -40,22 +41,22 @@ Then("The {string} panel is visible", async ({ page }, panelName) => { }); Then("Tab {string} is selected", async ({ page }, tabName) => { - const pageWithTabs = new DetailsPage(page); + const pageWithTabs = new Tabs(page); await pageWithTabs.verifyTabIsSelected(tabName); }); Then("Tab {string} is visible", async ({ page }, tabName) => { - const pageWithTabs = new DetailsPage(page); + const pageWithTabs = new Tabs(page); await pageWithTabs.verifyTabIsVisible(tabName); }); Then("Tab {string} is not visible", async ({ page }, tabName) => { - const pageWithTabs = new DetailsPage(page); + const pageWithTabs = new Tabs(page); await pageWithTabs.verifyTabIsNotVisible(tabName); }); When("User selects the Tab {string}", async ({ page }, tabName) => { - const detailsPage = new DetailsPage(page); + const detailsPage = new Tabs(page); await detailsPage.selectTab(tabName); });