Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/app/pages/search/components/SearchTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const SearchTabs: React.FC<SearchTabsProps> = ({
<Split hasGutter>
<SplitItem>
<Card isFullHeight>
<CardBody style={{ width: 241 }}>
<CardBody style={{ width: 241 }} aria-label="Filter panel">
{isTabActive("sboms") ? (
<FilterPanel
omitFilterCategoryKeys={[""]}
Expand Down
152 changes: 152 additions & 0 deletions e2e/tests/ui/assertions/SearchPageMatchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { expect as baseExpect } from "@playwright/test";
import type { SearchPage } from "../pages/search-page/SearchPage";
import type { MatcherResult } from "./types";

export interface SearchPageMatchers {
toHaveAutoFillHidden(): Promise<MatcherResult>;
toHaveRelevantAutoFillResults(searchText: string): Promise<MatcherResult>;
toHaveAutoFillCategoriesWithinLimit(limit: number): Promise<MatcherResult>;
}

type SearchPageMatcherDefinitions = {
readonly [K in keyof SearchPageMatchers]: (
receiver: SearchPage,
...args: Parameters<SearchPageMatchers[K]>
) => Promise<MatcherResult>;
};

export const searchPageAssertions =
baseExpect.extend<SearchPageMatcherDefinitions>({
toHaveAutoFillHidden: async (
searchPage: SearchPage,
): Promise<MatcherResult> => {
try {
const menu = searchPage.getAutoFillMenu();
await baseExpect(menu).not.toBeVisible();

return {
pass: true,
message: () => "Autofill menu is not visible",
};
} catch (error) {
return {
pass: false,
message: () =>
error instanceof Error ? error.message : String(error),
};
}
},

toHaveRelevantAutoFillResults: async (
searchPage: SearchPage,
searchText: string,
): Promise<MatcherResult> => {
try {
const menu = searchPage.getAutoFillMenu();
await baseExpect(menu).toBeVisible();

const menuItems = searchPage.getAutoFillMenuItems();
const count = await menuItems.count();

if (count === 0) {
return {
pass: false,
message: () => "Autofill menu has no items",
};
}

// Check that at least one menu item contains the search text
const searchTextLower = searchText.toLowerCase();
let foundMatch = false;

for (let i = 0; i < count; i++) {
const item = menuItems.nth(i);
const text = await item.textContent();
if (text?.toLowerCase().includes(searchTextLower)) {
foundMatch = true;
break;
}
}

if (!foundMatch) {
return {
pass: false,
message: () =>
`No autofill items contain search text "${searchText}"`,
};
}

return {
pass: true,
message: () => `Autofill has relevant results for "${searchText}"`,
};
} catch (error) {
return {
pass: false,
message: () =>
error instanceof Error ? error.message : String(error),
};
}
},

toHaveAutoFillCategoriesWithinLimit: async (
searchPage: SearchPage,
limit: number,
): Promise<MatcherResult> => {
try {
const menuItems = searchPage.getAutoFillMenuLinks();

const categoryCount: Record<string, number> = {
advisories: 0,
packages: 0,
sboms: 0,
vulnerabilities: 0,
};

const count = await menuItems.count();

for (let i = 0; i < count; i++) {
const link = menuItems.nth(i);
const href = await link.getAttribute("href");

if (href?.includes("/advisories/")) {
categoryCount.advisories++;
} else if (href?.includes("/packages/")) {
categoryCount.packages++;
} else if (href?.includes("/sboms/")) {
categoryCount.sboms++;
} else if (href?.includes("/vulnerabilities/")) {
categoryCount.vulnerabilities++;
}
}

// Check if any category exceeds the limit
const violations: string[] = [];
for (const [category, count] of Object.entries(categoryCount)) {
if (count > limit) {
violations.push(`${category}: ${count} > ${limit}`);
}
}

if (violations.length > 0) {
return {
pass: false,
message: () =>
`Categories exceed limit of ${limit}: ${violations.join(", ")}`,
};
}

return {
pass: true,
message: () =>
`All categories within limit of ${limit}: ${JSON.stringify(categoryCount)}`,
};
} catch (error) {
return {
pass: false,
message: () =>
error instanceof Error ? error.message : String(error),
};
}
},
});
62 changes: 62 additions & 0 deletions e2e/tests/ui/assertions/SearchPageTabsMatchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { expect as baseExpect } from "@playwright/test";
import type { SearchPageTabs } from "../pages/SearchPageTabs";
import type { MatcherResult } from "./types";

export interface SearchPageTabsMatchers {
toHaveTabCountAtLeast(minCount: number): Promise<MatcherResult>;
}

type SearchPageTabsMatcherDefinitions = {
readonly [K in keyof SearchPageTabsMatchers]: (
receiver: SearchPageTabs,
...args: Parameters<SearchPageTabsMatchers[K]>
) => Promise<MatcherResult>;
};

export const searchPageTabsAssertions =
baseExpect.extend<SearchPageTabsMatcherDefinitions>({
toHaveTabCountAtLeast: async (
searchPageTabs: SearchPageTabs,
minCount: number,
): Promise<MatcherResult> => {
try {
const badge = searchPageTabs.getBadge();

// Wait until the badge has some text
await baseExpect(badge).toHaveText(/[\d]/, { timeout: 60000 });

const countText = await badge.textContent();

// Remove anything that isn't a digit
const match = countText?.match(/\d+/);
if (!match) {
return {
pass: false,
message: () =>
`Could not parse badge count from tab: got "${countText}"`,
};
}

const count = parseInt(match[0], 10);

if (count < minCount) {
return {
pass: false,
message: () =>
`Expected tab to have at least ${minCount} results, but got ${count}`,
};
}

return {
pass: true,
message: () => `Tab has ${count} results (>= ${minCount})`,
};
} catch (error) {
return {
pass: false,
message: () =>
error instanceof Error ? error.message : String(error),
};
}
},
});
33 changes: 33 additions & 0 deletions e2e/tests/ui/assertions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,26 @@ import {
type FileUploadMatchers,
} from "./FileUploadMatchers";

import type { SearchPage } from "../pages/search-page/SearchPage";
import {
searchPageAssertions,
type SearchPageMatchers,
} from "./SearchPageMatchers";

import type { SearchPageTabs } from "../pages/SearchPageTabs";
import {
searchPageTabsAssertions,
type SearchPageTabsMatchers,
} from "./SearchPageTabsMatchers";

const merged = mergeExpects(
tableAssertions,
paginationAssertions,
toolbarAssertions,
dialogAssertions,
fileUploadAssertions,
searchPageAssertions,
searchPageTabsAssertions,
// Add more custom assertions here
);

Expand Down Expand Up @@ -89,6 +103,25 @@ function typedExpect(
): Omit<ReturnType<typeof merged<FileUpload>>, keyof FileUploadMatchers> &
FileUploadMatchers;

/**
* Overload from SearchPageMatchers.ts
*/
function typedExpect(
value: SearchPage,
): Omit<ReturnType<typeof merged<SearchPage>>, keyof SearchPageMatchers> &
SearchPageMatchers;

/**
* Overload from SearchPageTabsMatchers.ts
*/
function typedExpect(
value: SearchPageTabs,
): Omit<
ReturnType<typeof merged<SearchPageTabs>>,
keyof SearchPageTabsMatchers
> &
SearchPageTabsMatchers;

// Default overload
function typedExpect<T>(value: T): ReturnType<typeof merged<T>>;
function typedExpect<T>(value: T): unknown {
Expand Down
71 changes: 71 additions & 0 deletions e2e/tests/ui/features/@search/search.feature
Original file line number Diff line number Diff line change
@@ -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 "SBOMs" should be visible in the tab
And a total number of "Packages" should be visible in the tab
And a total number of "Vulnerabilities" should be visible in the tab
And a total number of "Advisories" should be visible in the tab

Scenario Outline: User toggles the "<types>" list and manipulates the list
When User selects the Tab "<types>"
Then the "<types>" list should have the "<filters>" filter set
And the "<types>" list should be sortable
And the "<types>" list should be limited to 10 items
And the user should be able to switch to next "<types>" items
And the user should be able to increase pagination for the "<types>"
And First column on the search results should have the link to "<types>" explorer pages

Examples:
|types|filters|
|SBOMs|Created on|
|Packages|Type, Architecture|
|Vulnerabilities|CVSS, Published|
|Advisories|Revision|

Scenario Outline: Download Links on the "<types>" Search Result list
When User selects the Tab "<types>"
Then Tab "<types>" is visible
And Download link should be available for the "<types>" list

Examples:
|types|
|SBOMs|
|Advisories|

Scenario Outline: Autofill shows results matched on <input>
When user starts typing a "<input>" in the search bar
Then the autofill dropdown should display items matching the "<input>"
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 "<types>"
When user types a "<type-instance>" in the search bar
And user presses Enter
And User selects the Tab "<types>"
Then the "<types>" list should display the specific "<type-instance>"
And the list should be limited to 10 items or less
And the user should be able to filter "<types>"
And user clicks on the "<type-instance>" "<types>" link
And the user should be navigated to the specific "<type-instance>" page

Examples:
|types|type-instance|
|SBOMs|quarkus-bom|
|Vulnerabilities|CVE-2022-45787|
|Packages|quarkus|
|Advisories|CVE-2022-45787|
Loading