diff --git a/tests/ui/features/@sbom-explorer/sbom-explorer.feature b/tests/ui/features/@sbom-explorer/sbom-explorer.feature index ba93907..8c6d55a 100644 --- a/tests/ui/features/@sbom-explorer/sbom-explorer.feature +++ b/tests/ui/features/@sbom-explorer/sbom-explorer.feature @@ -3,7 +3,7 @@ Feature: SBOM Explorer - View SBOM details Given User is authenticated Scenario Outline: View SBOM Overview - Given An ingested "" SBOM "" is available + Given An ingested SBOM "" is available When User visits SBOM details Page of "" Then The page title is "" And Tab "Info" is visible @@ -16,7 +16,7 @@ Feature: SBOM Explorer - View SBOM details | quarkus-bom | Scenario Outline: View SBOM Info (Metadata) - Given An ingested "" SBOM "" is available + Given An ingested SBOM "" is available When User visits SBOM details Page of "" Then Tab "Info" is selected Then "SBOM's name" is visible @@ -30,7 +30,7 @@ Feature: SBOM Explorer - View SBOM details | quarkus-bom | Scenario Outline: Downloading SBOM file - Given An ingested "" SBOM "" is available + Given An ingested SBOM "" is available When User visits SBOM details Page of "" Then "Download SBOM" action is invoked and downloaded filename is "" Then "Download License Report" action is invoked and downloaded filename is "" @@ -40,7 +40,7 @@ Feature: SBOM Explorer - View SBOM details | quarkus-bom | quarkus-bom.json | quarkus-bom_licenses.tar.gz | Scenario Outline: View list of SBOM Packages - Given An ingested "" SBOM "" is available + Given An ingested SBOM "" is available When User visits SBOM details Page of "" When User selects the Tab "Packages" # confirms its visible for all tabs @@ -59,11 +59,11 @@ Feature: SBOM Explorer - View SBOM details Then The Package table total results is greather than 1 Examples: - | sbomType | sbomName | packageName | - | SPDX | quarkus-bom | jdom | + | sbomName | packageName | + | quarkus-bom | jdom | - Scenario Outline: View SBOM Vulnerabilities - Given An ingested "" SBOM "" containing Vulnerabilities + Scenario Outline: View SBOM Vulnerabilities + Given An ingested SBOM "" containing Vulnerabilities When User visits SBOM details Page of "" When User selects the Tab "Vulnerabilities" When User Clicks on Vulnerabilities Tab Action @@ -73,28 +73,55 @@ Feature: SBOM Explorer - View SBOM details Then SBOM Name "" should be visible inside the tab Then SBOM Version should be visible inside the tab Then SBOM Creation date should be visible inside the tab - # Then List of related Vulnerabilities should be sorted by "CVSS" in descending order + Then List of related Vulnerabilities should be sorted by "Id" in ascending order Examples: - | sbomType | sbomName | - | SPDX | quarkus-bom | + | sbomName | + | quarkus-bom | @slow - Scenario Outline: Pagination of SBOM Vulnerabilities - Given An ingested "" SBOM "" containing Vulnerabilities + Scenario Outline: Pagination of SBOM Vulnerabilities table + Given An ingested SBOM "" containing Vulnerabilities When User visits SBOM details Page of "" When User selects the Tab "Vulnerabilities" Then Pagination of Vulnerabilities list works Examples: - | sbomType | sbomName | - | SPDX | quarkus-bom | + | sbomName | + | quarkus-bom | @slow - Scenario Outline: View paginated list of SBOM Packages - Given An ingested "" SBOM "" is available + Scenario Outline: View paginated list of SBOM Packages + Given An ingested SBOM "" is available When User visits SBOM details Page of "" When User selects the Tab "Packages" Then Pagination of Packages list works Examples: - | sbomType | sbomName | - | SPDX | quarkus-bom | + | sbomName | + | quarkus-bom | + + Scenario Outline: Check Column Headers of SBOM Explorer Vulnerabilities table + Given An ingested SBOM "" containing Vulnerabilities + When User visits SBOM details Page of "" + When User selects the Tab "Vulnerabilities" + Then List of Vulnerabilities has column "Id" + Then List of Vulnerabilities has column "Description" + Then List of Vulnerabilities has column "CVSS" + Then List of Vulnerabilities has column "Affected dependencies" + Then List of Vulnerabilities has column "Published" + Then List of Vulnerabilities has column "Updated" + Examples: + | sbomName | + | quarkus-bom | + + @slow + Scenario Outline: Sorting SBOM Vulnerabilities + Given An ingested SBOM "" containing Vulnerabilities + When User visits SBOM details Page of "" + When User selects the Tab "Vulnerabilities" + Then Table column "Description" is not sortable + Then Sorting of "Id, Affected dependencies, Published, Updated" Columns Works + #Then Sorting of "CVSS" Columns works + # Bug: https://issues.redhat.com/browse/TC-2598 + Examples: + | sbomName | + | quarkus-bom | \ No newline at end of file diff --git a/tests/ui/features/@sbom-explorer/sbom-explorer.step.ts b/tests/ui/features/@sbom-explorer/sbom-explorer.step.ts index 190f061..3697885 100644 --- a/tests/ui/features/@sbom-explorer/sbom-explorer.step.ts +++ b/tests/ui/features/@sbom-explorer/sbom-explorer.step.ts @@ -9,13 +9,10 @@ export const { Given, When, Then } = createBdd(); const PACKAGE_TABLE_NAME = "Package table"; const VULN_TABLE_NAME = "Vulnerability table"; -Given( - "An ingested {string} SBOM {string} is available", - async ({ page }, _sbomType, sbomName) => { - const searchPage = new SearchPage(page); - await searchPage.dedicatedSearch("SBOMs", sbomName); - } -); +Given("An ingested SBOM {string} is available", async ({ page }, sbomName) => { + const searchPage = new SearchPage(page, "SBOMs"); + await searchPage.dedicatedSearch(sbomName); +}); When( "User visits SBOM details Page of {string}", @@ -83,10 +80,10 @@ Then( ); Given( - "An ingested {string} SBOM {string} containing Vulnerabilities", - async ({ page }, _sbomType, sbomName) => { - const searchPage = new SearchPage(page); - await searchPage.dedicatedSearch("SBOMs", sbomName); + "An ingested SBOM {string} containing Vulnerabilities", + async ({ page }, sbomName) => { + const searchPage = new SearchPage(page, "SBOMs"); + await searchPage.dedicatedSearch(sbomName); const element = await page.locator( `xpath=(//tr[contains(.,'${sbomName}')]/td[@data-label='Vulnerabilities']/div)[1]` ); @@ -149,10 +146,10 @@ Then( ); Then( - "List of related Vulnerabilities should be sorted by {string} in descending order", + "List of related Vulnerabilities should be sorted by {string} in ascending order", async ({ page }, columnName) => { const toolbarTable = new ToolbarTable(page, VULN_TABLE_NAME); - await toolbarTable.verifyTableIsSortedBy(columnName, false); + await toolbarTable.verifyTableIsSortedBy(columnName, true); } ); @@ -167,3 +164,26 @@ Then("Pagination of Packages list works", async ({ page }) => { const vulnTableTopPagination = `xpath=//div[@id="package-table-pagination-top"]`; await toolbarTable.verifyPagination(vulnTableTopPagination); }); + +Then( + "List of Vulnerabilities has column {string}", + async ({ page }, columnHeader) => { + const toolbarTable = new ToolbarTable(page, VULN_TABLE_NAME); + await toolbarTable.verifyTableHeaderContains(columnHeader); + } +); + +Then( + "Table column {string} is not sortable", + async ({ page }, columnHeader) => { + const toolbarTable = new ToolbarTable(page, VULN_TABLE_NAME); + await toolbarTable.verifyColumnIsNotSortable(columnHeader); + } +); + +Then("Sorting of {string} Columns Works", async ({ page }, columnHeaders) => { + const headers = columnHeaders.split(`,`).map((column) => column.trim()); + const toolbarTable = new ToolbarTable(page, VULN_TABLE_NAME); + const vulnTableTopPagination = `xpath=//div[@id="vulnerability-table-pagination-top"]`; + await toolbarTable.verifySorting(vulnTableTopPagination, headers); +}); diff --git a/tests/ui/features/@vulnerability-explorer/vulnerability-explorer.step.ts b/tests/ui/features/@vulnerability-explorer/vulnerability-explorer.step.ts index 3f5a0ad..5c79307 100644 --- a/tests/ui/features/@vulnerability-explorer/vulnerability-explorer.step.ts +++ b/tests/ui/features/@vulnerability-explorer/vulnerability-explorer.step.ts @@ -10,8 +10,8 @@ const ADVISORY_TABLE_NAME = "Advisory table"; Given( "User visits Vulnerability details Page of {string}", async ({ page }, vulnerabilityID) => { - const searchPage = new SearchPage(page); - await searchPage.dedicatedSearch("Vulnerabilities", vulnerabilityID); + const searchPage = new SearchPage(page, "Vulnerabilities"); + await searchPage.dedicatedSearch(vulnerabilityID); await page.getByRole("link", { name: vulnerabilityID }).click(); } ); diff --git a/tests/ui/helpers/SearchPage.ts b/tests/ui/helpers/SearchPage.ts index cf82a0d..e41bb8c 100644 --- a/tests/ui/helpers/SearchPage.ts +++ b/tests/ui/helpers/SearchPage.ts @@ -3,9 +3,11 @@ import { DetailsPage } from "./DetailsPage"; export class SearchPage { page: Page; + menu: String; - constructor(page: Page) { + constructor(page: Page, menu: String) { this.page = page; + this.menu = menu; } /** @@ -13,9 +15,9 @@ export class SearchPage { * @param menu Option from Vertical navigation menu * @param data Search data to filter */ - async dedicatedSearch(menu: string, data: string) { + async dedicatedSearch(data: string) { await this.page.goto("/"); - await this.page.getByRole("link", { name: menu }).click(); + await this.page.getByRole("link", { name: `${this.menu}` }).click(); const detailsPage = new DetailsPage(this.page); await detailsPage.waitForData(); await detailsPage.verifyDataAvailable(); diff --git a/tests/ui/helpers/ToolbarTable.ts b/tests/ui/helpers/ToolbarTable.ts index 49e51b3..b66b0d5 100644 --- a/tests/ui/helpers/ToolbarTable.ts +++ b/tests/ui/helpers/ToolbarTable.ts @@ -1,4 +1,5 @@ -import { expect, Page } from "@playwright/test"; +import { expect, Locator, Page } from "@playwright/test"; +import { fail } from "assert"; export class ToolbarTable { private _page: Page; private _tableName: string; @@ -59,16 +60,10 @@ export class ToolbarTable { * And bottom section `//div[@id="vulnerability-table-pagination-bottom"]` */ async verifyPagination(parentElem: string) { - const section = this._page.locator(parentElem); const perPageValues = [10, 20, 50, 100]; const totalRows = await this.getTotalRowsFromPagination(parentElem); for (const value of perPageValues) { - const firstPage = section.getByRole("button", { - name: "Go to first page", - }); - if (await firstPage.isEnabled()) { - await firstPage.click(); - } + await this.goToFirstPage(parentElem); let expectedPagecount = Math.trunc(totalRows / value); let remainingRows = totalRows % value; if (remainingRows > 0) { @@ -113,16 +108,7 @@ export class ToolbarTable { * @returns total row count from pagination dropdown */ async getTotalRowsFromPagination(parentElem: string): Promise { - const tableError = this._page.locator( - `xpath=(//tbody[@aria-label="Table error"])[1]` - ); - if (await tableError.isVisible()) { - await expect(tableError, "No Data available").not.toBeVisible(); - } - const progressBar = this._page.getByRole("gridcell", { - name: "Loading...", - }); - await progressBar.waitFor({ state: "hidden", timeout: 5000 }); + await this.waitForTableContent(); const pagination = this._page.locator(parentElem); const totalResultsText = await pagination .locator(`xpath=//button//b[not(contains (.,'-'))]`) @@ -204,7 +190,8 @@ export class ToolbarTable { } /** - * + * Verifies the Pagination Row Count + * Example, in pagination counter `1-10 of 61` - it verifies the @param expMinCount equals to 1 and @param expMaxCount equals to 10 * @param parentElem required to differentiate top and bottom pagination * @param expMinCount Expected Min count on the counter * @param expMaxCount Expected Max count on the counter @@ -226,6 +213,242 @@ export class ToolbarTable { await expect(max).toEqual(expMaxCount); } + /** + * Verifies the columnHeader given is visible + * @param columnHeader Table Column Header + */ + async verifyTableHeaderContains(columnHeader: string) { + const table = this.getTable(); + await table.getByRole("columnheader", { name: columnHeader }).isVisible(); + } + + /** + * Verifies the given Table header doesn't have sortable attribute + * @param columnHeader Table Column Header + */ + async verifyColumnIsNotSortable(columnHeader: string) { + //const table = this.getTable(); + const elem = await this._page.getByRole("columnheader", { + name: `${columnHeader}`, + }); + await elem.click(); + await expect(elem).not.toHaveAttribute("aria-label"); + } + + /** + * Navigate to the First page of the WebTable + * @param parentElem ParentElement to identify Pagination + */ + async goToFirstPage(parentElem: string) { + const firstPage = this._page.locator(parentElem).getByRole("button", { + name: "Go to first page", + }); + if (await firstPage.isEnabled()) { + await firstPage.click(); + } + } + + /** + * Wait for Table data - Check for Table Error not occurs and Wait for 5000ms + */ + async waitForTableContent() { + const tableError = this._page.locator( + `xpath=(//tbody[@aria-label="Table error"])[1]` + ); + if (await tableError.isVisible()) { + await expect(tableError, "No Data available").not.toBeVisible(); + } + const progressBar = this._page.getByRole("gridcell", { + name: "Loading...", + }); + await progressBar.waitFor({ state: "hidden", timeout: 10000 }); + } + + /** + * Retrieve Table header and Row values in array + * @param parentElem ParentElement of pagination + * @returns two dimensional string which contains the contents of table + */ + async getTableRows(parentElem: string): 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) { + 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(); + } + return tableData; + } + + /** + * Sort table for the given column index and sorting order + * @param table Source table for Sorting + * @param header Target header to be sorted + * @param sorting sorting order + * @returns tow dimensional array containing sorted table based on the given column in given sorting order + */ + async sortTable( + table: string[][], + header: string, + sorting: string = `ascending` + ): Promise { + const headerRow = table[0]; + const dataRow = table.slice(1); + const index = headerRow.indexOf(header); + let row = 0; + if (index < 0) { + fail("Given header not found"); + } + for (const data of dataRow) { + if (data[index] !== ``) { + row += 1; + break; + } + } + let isDate = this.isValidDate(dataRow[row][index]); + let isCVSS = this.isCVSS(dataRow[row][index]); + let isCVE = this.isCVE(dataRow[row][index]); + const sortedRows = [...dataRow].sort((rowA, rowB) => { + let compare: any; + let valueA = rowA[index]; + let valueB = rowB[index]; + if (isDate) { + let dateA = new Date(valueA); + let dateB = new Date(valueB); + compare = dateA.getTime() - dateB.getTime(); + } else if (isCVSS) { + let cvssA = this.getCVSS(valueA); + let cvssB = this.getCVSS(valueB); + compare = cvssA - cvssB; + } else if (isCVE) { + let [cveYA, cveIA] = this.getCVE(valueA); + let [cveYB, cveIB] = this.getCVE(valueB); + compare = cveYA !== cveYB ? cveYA - cveYB : cveIA - cveIB; + } else { + compare = valueA.localeCompare(valueB); + } + + if (sorting == "descending") { + compare *= -1; + } + return compare; + }); + return [headerRow, ...sortedRows]; + } + + /** + * To verify the given string is in Date format + * @param dateString Input date value + * @returns true if the given input is date + */ + isValidDate(dateString: string): boolean { + const validDate = new Date(dateString); + return !isNaN(validDate.getTime()); + } + + /** + * To verify the given string is in CVSS format + * @param cvssString input CVSS value + * @returns true if the given input is in CVSS format + */ + isCVSS(cvssString: string): boolean { + const cvssRegex = /^.+\((\d*\.*\d+?)\)$/; + return cvssRegex.test(cvssString) ? true : false; + } + + /** + * To verify the given string is in CVE format + * @param cve input CVSS value + * @returns true if the given input is in CVE format + */ + isCVE(cve: string): boolean { + const cveRegex = /^CVE-(\d+?)-(\d+?)$/; + return cveRegex.test(cve) ? true : false; + } + + /** + * To retrieve CVSS score from the given string + * @param cvssString input CVSS value + * @returns CVSS score if the given input is in CVSS format + */ + getCVSS(cvssString: string): number { + const cvssRegex = /^.+\((\d*\.*\d+?)\)$/; + let cvssScore = cvssString.match(cvssRegex)!; + return parseFloat(cvssScore[1]!); + } + + /** + * To retrieve CVE year and ID from the given string + * @param cve input CVE value + * @returns CVE Year and ID if the given input is in CVE format + */ + getCVE(cve: string): [number, number] { + const matchCVE = cve.match(/^CVE-(\d+)-(\d+)$/); + if (!matchCVE) throw new Error(`Invalid CVE format: ${cve}`); + return [Number(matchCVE[1]), Number(matchCVE[2])]; + } + + /** + * To sort a column for the given order + * @param columnHeader column Name + * @param sortOrder Sorting order Ascending or descending + * @returns Boolean based on whether column sorted with expected order + */ + async sortColumn(columnHeader: string, sortOrder: string): Promise { + const headerElem = await this._page.getByRole("columnheader", { + name: `${columnHeader}`, + }); + for (let i = 0; i < 3; i++) { + const sort = await headerElem.getAttribute(`aria-sort`); + if (sort === sortOrder) { + return true; + } else { + await headerElem.getByRole("button").click(); + } + } + return false; + } + + /** + * Verifies Sorting of given Columns in Ascending and Descending orders + * @param parentElem ParentElement for Pagination + * @param columnHeaders List of column headers to be verified + */ + async verifySorting( + parentElem: string, + columnHeaders: string[], + perPageCount: string = "100" + ) { + await this.waitForTableContent(); + await this.selectPerPage(parentElem, perPageCount); + for (let header of columnHeaders) { + for (let order of [`ascending`, `descending`]) { + const sorted = await this.sortColumn(header, order); + sorted + ? null + : (() => { + throw new Error( + `Sorting failed for the column ${header} with order ${order}` + ); + })(); + let sourceData = await this.getTableRows(parentElem); + let sortedData = await this.sortTable(await sourceData, header, order); + await expect( + sourceData, + `Column ${header} sorting ${order} order` + ).toEqual(sortedData); + } + } + } + private getTable() { return this._page.locator(`table[aria-label="${this._tableName}"]`); }