diff --git a/e2e/tests/ui/assertions/DetailsPageMatchers.ts b/e2e/tests/ui/assertions/DetailsPageMatchers.ts new file mode 100644 index 000000000..09c96391d --- /dev/null +++ b/e2e/tests/ui/assertions/DetailsPageMatchers.ts @@ -0,0 +1,39 @@ +import { expect as baseExpect } from "@playwright/test"; +import type { DetailsPage } from "../helpers/DetailsPage"; +import type { MatcherResult } from "./types"; + +export interface DetailsPageMatchers { + toHaveVisibleAction(actionName: string): Promise; +} + +type DetailsPageMatcherDefinitions = { + readonly [K in keyof DetailsPageMatchers]: ( + receiver: DetailsPage, + ...args: Parameters + ) => Promise; +}; + +export const detailsPageAssertions = + baseExpect.extend({ + toHaveVisibleAction: async ( + detailsPage: DetailsPage, + actionName: string, + ): Promise => { + try { + await baseExpect( + detailsPage.page.getByRole("menuitem", { name: actionName }), + ).toBeVisible(); + return { + pass: true, + message: () => + `Action "${actionName}" is visible in the actions menu`, + }; + } catch (error) { + return { + pass: false, + message: () => + error instanceof Error ? error.message : String(error), + }; + } + }, + }); diff --git a/e2e/tests/ui/assertions/index.ts b/e2e/tests/ui/assertions/index.ts index 52aa54765..879b8f522 100644 --- a/e2e/tests/ui/assertions/index.ts +++ b/e2e/tests/ui/assertions/index.ts @@ -22,12 +22,19 @@ import { type FileUploadMatchers, } from "./FileUploadMatchers"; +import type { DetailsPage } from "../helpers/DetailsPage"; +import { + detailsPageAssertions, + type DetailsPageMatchers, +} from "./DetailsPageMatchers"; + const merged = mergeExpects( tableAssertions, paginationAssertions, toolbarAssertions, dialogAssertions, fileUploadAssertions, + detailsPageAssertions, // Add more custom assertions here ); @@ -89,6 +96,14 @@ function typedExpect( ): Omit>, keyof FileUploadMatchers> & FileUploadMatchers; +/** + * Overload from DetailsPageMatchers.ts + */ +function typedExpect( + value: DetailsPage, +): Omit>, keyof DetailsPageMatchers> & + DetailsPageMatchers; + // Default overload function typedExpect(value: T): ReturnType>; function typedExpect(value: T): unknown { diff --git a/e2e/tests/ui/features/@license-export_cdx/license-export_cdx.feature b/e2e/tests/ui/features/@license-export_cdx/license-export_cdx.feature new file mode 100644 index 000000000..1fc113c7e --- /dev/null +++ b/e2e/tests/ui/features/@license-export_cdx/license-export_cdx.feature @@ -0,0 +1,84 @@ +Feature: License Explorer + As a Platform Eng + I want to be able to download the licenses in a CSV file format from a specific SBOM + +Background: + +Scenario: Verify Download Licences option on SBOM Search Results page for CycloneDX SBOM + Given User Searches for CycloneDX SBOM "" using Search Text box + When User Selects CycloneDX SBOM "" from the Search Results + And User Clicks on SBOM name hyperlink from the Search Results + And User Clicks "Action" button + Then "Download License Report" Option should be visible + + Examples: + | sbomName | + | liboqs | + +Scenario Outline: User Downloads license information for CycloneDX SBOM from SBOM Search Results page + Given User Searches for CycloneDX SBOM "" using Search Text box + When User Selects CycloneDX SBOM "" from the Search Results + And User Clicks on SBOM name hyperlink from the Search Results + And User Clicks "Action" button + And Selects "Download License Report" option + Then Licenses associated with the SBOM should be downloaded in TAR.GZ format using the SBOM name + + Examples: + | sbomName | + | liboqs | + +Scenario: Verify Download Licences option on SBOM Explorer page for CycloneDX SBOM + Given User Searches for CycloneDX SBOM "" using Search Text box and Navigates to Search results page + When User Selects CycloneDX SBOM "" from the Search Results + And User Clicks on SBOM name hyperlink from the Search Results + Then Application Navigates to SBOM Explorer page + And User Clicks "Action" button + And "Download License Report" Option should be visible + + Examples: + | sbomName | + | liboqs | + +Scenario: User Downloads license information for CycloneDX SBOM from SBOM Explorer page + Given User is on SBOM Explorer page for the CycloneDX SBOM "" + And User Clicks on "Download License Report" button + Then Licenses associated with the SBOM should be downloaded in TAR.GZ format using the SBOM name + + Examples: + | sbomName | + | liboqs | + +Scenario: Verify the files on downloaded CycloneDX SBOM license TAR.GZ + Given User has Downloaded the License information for CycloneDX SBOM "" + When User extracts the Downloaded license TAR.GZ file + Then Extracted files should contain two CSVs, one for Package license information and another one for License reference + + Examples: + | sbomName | + | liboqs | + +Scenario: Verify the headers on CycloneDX SBOM package License CSV file + Given User extracted the CycloneDX SBOM "" license compressed file + When User Opens the package license information file + Then The file should have the following headers - SBOM name, SBOM id, package name, package group, package version, package purl, package cpe and license + + Examples: + | sbomName | + | liboqs | + +Scenario: Verify the headers on CycloneDX SBOM License reference CSV file + Given User extracted the CycloneDX SBOM "" license compressed file + When User Opens the license reference file + Then The file should have the following headers - licenseId, name, extracted text and comment + + Examples: + | sbomName | + | liboqs | + +Scenario: Verify the contents on CycloneDX SBOM license reference CSV file + Given User is on license reference "" file + Then The License reference CSV should be empty + + Examples: + | sbomName | + | liboqs | diff --git a/e2e/tests/ui/features/@license-export_cdx/license-export_cdx.step.ts b/e2e/tests/ui/features/@license-export_cdx/license-export_cdx.step.ts new file mode 100644 index 000000000..77ce1b250 --- /dev/null +++ b/e2e/tests/ui/features/@license-export_cdx/license-export_cdx.step.ts @@ -0,0 +1,205 @@ +import { createBdd } from "playwright-bdd"; +import * as fs from "node:fs"; + +import { test } from "../../fixtures"; + +import { expect } from "../../assertions"; + +import { SbomListPage } from "../../pages/sbom-list/SbomListPage"; +import { SbomDetailsPage } from "../../pages/sbom-details/SbomDetailsPage"; +import { DetailsPage } from "../../helpers/DetailsPage"; +import { SearchPage } from "../../helpers/SearchPage"; +import { clickAndVerifyDownload } from "../../pages/Helpers"; +import { + downloadLicenseReport, + extractLicenseReport, + findCsvWithHeader, +} from "../../pages/LicenseExportHelpers"; + +export const { Given, When, Then } = createBdd(test); + +let downloadedFilename: string; +let downloadedFilePath: string; +let extractionPath: string; +let packageLicenseFilePath: string; +let licenseReferenceFilePath: string; + +Given( + "User Searches for CycloneDX SBOM {string} using Search Text box", + async ({ page }, sbomName: string) => { + const listPage = await SbomListPage.build(page); + const toolbar = await listPage.getToolbar(); + await toolbar.applyFilter({ "Filter text": sbomName }); + }, +); + +Given( + "User Searches for CycloneDX SBOM {string} using Search Text box and Navigates to Search results page", + async ({ page }, sbomName: string) => { + const searchPage = new SearchPage(page, "Search"); + await searchPage.generalSearch("SBOMs", sbomName); + }, +); + +When( + "User Selects CycloneDX SBOM {string} from the Search Results", + async ({ page }, sbomName: string) => { + const listPage = await SbomListPage.fromCurrentPage(page); + const table = await listPage.getTable(); + await table.waitUntilDataIsLoaded(); + + await expect(table).toHaveColumnWithValue("Name", sbomName); + }, +); + +When( + "User Clicks on SBOM name hyperlink from the Search Results", + async ({ page }) => { + const listPage = await SbomListPage.fromCurrentPage(page); + const table = await listPage.getTable(); + const rows = await table.getRows(); + await rows.first().getByRole("link").click(); + }, +); + +Then("Application Navigates to SBOM Explorer page", async ({ page }) => { + await expect(page).toHaveURL(/\/sboms\/[^/]+/); +}); + +When('User Clicks "Action" button', async ({ page }) => { + const detailsPage = new DetailsPage(page); + await detailsPage.openActionsMenu(); +}); + +Then('"Download License Report" Option should be visible', async ({ page }) => { + const detailsPage = new DetailsPage(page); + await expect(detailsPage).toHaveVisibleAction("Download License Report"); +}); + +When('Selects "Download License Report" option', async ({ page }) => { + const detailsPage = new DetailsPage(page); + downloadedFilename = await clickAndVerifyDownload( + page, + async () => + await detailsPage.page + .getByRole("menuitem", { name: "Download License Report" }) + .click(), + ); +}); + +Then( + "Licenses associated with the SBOM should be downloaded in TAR.GZ format using the SBOM name", + async ({ page }) => { + const sbomName = await page.getByRole("heading").first().innerText(); + expect(downloadedFilename).toContain(sbomName); + expect(downloadedFilename.endsWith(".tar.gz")).toBeTruthy(); + }, +); + +Given( + "User is on SBOM Explorer page for the CycloneDX SBOM {string}", + async ({ page }, sbomName: string) => { + await SbomDetailsPage.build(page, sbomName); + }, +); + +When('User Clicks on "Download License Report" button', async ({ page }) => { + const detailsPage = new DetailsPage(page); + await detailsPage.openActionsMenu(); + + downloadedFilename = await clickAndVerifyDownload( + page, + async () => + await detailsPage.page + .getByRole("menuitem", { name: "Download License Report" }) + .click(), + ); +}); + +Given( + "User has Downloaded the License information for CycloneDX SBOM {string}", + async ({ page }, sbomName: string) => { + await SbomDetailsPage.build(page, sbomName); + downloadedFilePath = await downloadLicenseReport(page); + }, +); + +When("User extracts the Downloaded license TAR.GZ file", async () => { + extractionPath = await extractLicenseReport(downloadedFilePath); +}); + +Then( + "Extracted files should contain two CSVs, one for Package license information and another one for License reference", + async () => { + const files = fs.readdirSync(extractionPath); + const csvFiles = files.filter((file) => file.endsWith(".csv")); + expect(csvFiles.length).toBe(2); + }, +); + +Given( + "User extracted the CycloneDX SBOM {string} license compressed file", + async ({ page }, sbomName: string) => { + await SbomDetailsPage.build(page, sbomName); + downloadedFilePath = await downloadLicenseReport(page); + extractionPath = await extractLicenseReport(downloadedFilePath); + }, +); + +When("User Opens the package license information file", async () => { + packageLicenseFilePath = findCsvWithHeader( + extractionPath, + "SBOM name", + "Package license information file", + ); +}); + +Then( + "The file should have the following headers - SBOM name, SBOM id, package name, package group, package version, package purl, package cpe and license", + async () => { + const content = fs.readFileSync(packageLicenseFilePath, "utf-8"); + const headers = content.split("\n")[0].trim(); + const expectedHeaders = + '"SBOM name"\t"SBOM id"\t"package name"\t"package group"\t"package version"\t"package purl"\t"package cpe"\t"license"\t"license type"'; + expect(headers).toBe(expectedHeaders); + }, +); + +When("User Opens the license reference file", async () => { + licenseReferenceFilePath = findCsvWithHeader( + extractionPath, + "licenseId", + "License reference file", + ); +}); + +Then( + "The file should have the following headers - licenseId, name, extracted text and comment", + async () => { + const content = fs.readFileSync(licenseReferenceFilePath, "utf-8"); + const headers = content.split("\n")[0].trim(); + const expectedHeaders = '"licenseId"\t"name"\t"extracted text"\t"comment"'; + expect(headers).toBe(expectedHeaders); + }, +); + +Given( + "User is on license reference {string} file", + async ({ page }, sbomName: string) => { + await SbomDetailsPage.build(page, sbomName); + downloadedFilePath = await downloadLicenseReport(page); + extractionPath = await extractLicenseReport(downloadedFilePath); + licenseReferenceFilePath = findCsvWithHeader( + extractionPath, + "licenseId", + "License reference file", + ); + }, +); + +Then("The License reference CSV should be empty", async () => { + const content = fs.readFileSync(licenseReferenceFilePath, "utf-8"); + const lines = content.trim().split("\n"); + // Only header should be present + expect(lines.length).toBe(1); +}); diff --git a/e2e/tests/ui/helpers/DetailsPage.ts b/e2e/tests/ui/helpers/DetailsPage.ts index 72484f070..46ff026c3 100644 --- a/e2e/tests/ui/helpers/DetailsPage.ts +++ b/e2e/tests/ui/helpers/DetailsPage.ts @@ -22,6 +22,12 @@ export class DetailsPage { await this.page.getByRole("menuitem", { name: actionName }).click(); } + async openActionsMenu() { + const actionsButton = this.page.getByRole("button", { name: "Actions" }); + await actionsButton.click(); + await expect(actionsButton).toHaveAttribute("aria-expanded", "true"); + } + async clickOnPageButton(buttonName: string) { await this.page.getByRole("button", { name: buttonName }).click(); } @@ -31,7 +37,7 @@ export class DetailsPage { } async verifyActionIsAvailable(actionName: string) { - await this.page.getByRole("button", { name: "Actions" }).click(); + await this.openActionsMenu(); await expect( this.page.getByRole("menuitem", { name: actionName }), ).toBeVisible(); diff --git a/e2e/tests/ui/pages/Helpers.ts b/e2e/tests/ui/pages/Helpers.ts index 66222ebc1..3a5234369 100644 --- a/e2e/tests/ui/pages/Helpers.ts +++ b/e2e/tests/ui/pages/Helpers.ts @@ -59,6 +59,23 @@ export const clickAndVerifyDownload = async ( return await verifyDownload(download); }; +/** + * Handles click action that triggers a download and returns the Download object + * @param page The Playwright Page object + * @param clickAction The async function that performs the click + * @returns The Download object + */ +export const clickAndDownload = async ( + page: Page, + clickAction: () => Promise, +): Promise => { + const [download] = await Promise.all([ + page.waitForEvent("download"), + clickAction(), + ]); + return download; +}; + /** * Verifies comma-delimited expected values against child elements * Useful for comparing lists like qualifiers, tags, labels, severity values, etc. diff --git a/e2e/tests/ui/pages/LicenseExportHelpers.ts b/e2e/tests/ui/pages/LicenseExportHelpers.ts new file mode 100644 index 000000000..1d7f79b70 --- /dev/null +++ b/e2e/tests/ui/pages/LicenseExportHelpers.ts @@ -0,0 +1,81 @@ +import type { Page } from "@playwright/test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { DetailsPage } from "../helpers/DetailsPage"; +import { clickAndDownload } from "./Helpers"; + +export const downloadLicenseReport = async (page: Page): Promise => { + const detailsPage = new DetailsPage(page); + await detailsPage.openActionsMenu(); + + const download = await clickAndDownload( + page, + async () => + await detailsPage.page + .getByRole("menuitem", { name: "Download License Report" }) + .click(), + ); + + const sanitizedFilename = download + .suggestedFilename() + .replace(/[/\\.]/g, "_"); + const savePath = path.join( + os.tmpdir(), + `license-report-${Date.now()}-${sanitizedFilename}`, + ); + await download.saveAs(savePath); + return savePath; +}; + +export const extractLicenseReport = async ( + downloadedFilePath: string, +): Promise => { + const execFileAsync = promisify(execFile); + const extractionPath = path.join( + path.dirname(downloadedFilePath), + "extracted", + ); + if (!fs.existsSync(extractionPath)) { + fs.mkdirSync(extractionPath); + } + + await execFileAsync("tar", [ + "-xzf", + downloadedFilePath, + "-C", + extractionPath, + ]); + return extractionPath; +}; + +export const findCsvWithHeader = ( + extractionPath: string, + headerIdentifier: string, + fileDescription: string, +): string => { + const files = fs.readdirSync(extractionPath); + const csvFiles = files.filter((file) => file.endsWith(".csv")); + let foundFile: string | undefined; + + for (const file of csvFiles) { + const filePath = path.join(extractionPath, file); + const content = fs.readFileSync(filePath, "utf-8"); + const firstLine = content.split("\n")[0]; + if (firstLine.includes(headerIdentifier)) { + foundFile = filePath; + break; + } + } + + if (!foundFile) { + throw new Error( + `${fileDescription} not found among extracted files: ` + + csvFiles.join(", "), + ); + } + + return foundFile; +};