diff --git a/app/components/ak-breadcrumbs/auto-trail/index.hbs b/app/components/ak-breadcrumbs/auto-trail/index.hbs
index 26842e85e0..21e2f6902b 100644
--- a/app/components/ak-breadcrumbs/auto-trail/index.hbs
+++ b/app/components/ak-breadcrumbs/auto-trail/index.hbs
@@ -3,6 +3,7 @@
@alignItems='center'
@tag='ul'
data-test-ak-breadcrumbs-auto-trail-container
+ data-test-cy='ak-breadcrumbs-auto-trail-container'
...attributes
>
{{#each this.breadcrumbsService.breadcrumbItems as |item|}}
diff --git a/app/components/ak-pagination/index.hbs b/app/components/ak-pagination/index.hbs
index a78470546f..963a208888 100644
--- a/app/components/ak-pagination/index.hbs
+++ b/app/components/ak-pagination/index.hbs
@@ -15,9 +15,9 @@
@renderInPlace={{true}}
@verticalPosition={{or @paginationSelectOptionsVertPosition 'above'}}
@triggerClass={{this.classes.selectClass}}
+ aria-label='pagination item per page options'
{{style width='50px'}}
data-test-pagination-select
- data-test-cy='paginationItemPerPageOptions'
as |aks|
>
{{aks.label}}
@@ -26,7 +26,11 @@
{{#if @totalItems}}
-
+
{{@startItemIdx}}-{{@endItemIdx}}
of
{{@totalItems}}
@@ -49,6 +53,7 @@
@leftIconClass={{this.classes.prevButtonIconClass}}
@color='neutral'
@variant='outlined'
+ aria-label='pagination previous button'
local-class='ak-pagination-prev-button'
{{on 'click' (or @prevAction this.noop)}}
data-test-pagination-prev-btn
@@ -77,6 +82,7 @@
@color='neutral'
@variant='outlined'
@rightIconClass={{this.classes.nextButtonIconClass}}
+ aria-label='pagination next button'
local-class='ak-pagination-next-button'
{{on 'click' (or @nextAction this.noop)}}
data-test-pagination-next-btn
diff --git a/app/components/sbom/app-list/action/index.hbs b/app/components/sbom/app-list/action/index.hbs
index e068f25b3e..0dba9e5b99 100644
--- a/app/components/sbom/app-list/action/index.hbs
+++ b/app/components/sbom/app-list/action/index.hbs
@@ -2,6 +2,7 @@
data-test-sbomApp-actionBtn
{{on 'click' this.handleOpenMenu}}
disabled={{this.hasNoSbomScan}}
+ data-test-cy='sbomApp-actionBtn'
>
@@ -22,6 +23,7 @@
@model={{it.model}}
@onClick={{it.onClick}}
@divider={{it.divider}}
+ aria-label='sbom app action menu item'
as |li|
>
diff --git a/app/components/sbom/app-list/header/index.hbs b/app/components/sbom/app-list/header/index.hbs
index c678a5cebf..2bfb549a20 100644
--- a/app/components/sbom/app-list/header/index.hbs
+++ b/app/components/sbom/app-list/header/index.hbs
@@ -27,6 +27,7 @@
{{on 'input' this.onSearchQueryChange}}
local-class='search-package-name-input'
data-test-sbomApp-searchInput
+ data-test-cy='sbomApp-searchInput'
>
<:leftAdornment>
@@ -64,6 +65,7 @@
{{style width='auto'}}
class='select-platform-class'
data-test-select-platform-container
+ data-test-cy='sbom-platform-filterSelect'
as |platformObject|
>
diff --git a/app/components/sbom/app-list/index.hbs b/app/components/sbom/app-list/index.hbs
index 6c2cc46056..c49a165da7 100644
--- a/app/components/sbom/app-list/index.hbs
+++ b/app/components/sbom/app-list/index.hbs
@@ -34,6 +34,7 @@
@direction='column'
@alignItems='center'
@spacing='1'
+ data-test-cy='sbomApp-emptyTextContainer'
>
{{t 'sbomModule.sbomAppEmptyText.title'}}
@@ -53,6 +54,7 @@
<:default>
diff --git a/app/components/sbom/app-scan/list/index.hbs b/app/components/sbom/app-scan/list/index.hbs
index 99aab37089..36b899460f 100644
--- a/app/components/sbom/app-scan/list/index.hbs
+++ b/app/components/sbom/app-scan/list/index.hbs
@@ -50,6 +50,7 @@
{{style cursor='pointer'}}
@onClick={{this.handleSbomScanClick}}
data-test-sbomScan-row
+ data-test-cy='sbomScan-row'
as |r|
>
diff --git a/app/components/sbom/app-scan/list/view-report/index.hbs b/app/components/sbom/app-scan/list/view-report/index.hbs
index d25f588e36..c085b61e45 100644
--- a/app/components/sbom/app-scan/list/view-report/index.hbs
+++ b/app/components/sbom/app-scan/list/view-report/index.hbs
@@ -2,6 +2,7 @@
data-test-sbomScan-viewReportBtn
disabled={{this.disableViewReport}}
{{on 'click' (fn @onViewReportClick @sbomFile)}}
+ data-test-cy='sbomScan-viewReportBtn'
>
\ No newline at end of file
diff --git a/app/components/sbom/component-details/index.hbs b/app/components/sbom/component-details/index.hbs
index c4836f48f8..3e2677d1f3 100644
--- a/app/components/sbom/component-details/index.hbs
+++ b/app/components/sbom/component-details/index.hbs
@@ -20,6 +20,7 @@
@route={{item.route}}
@currentWhen={{item.activeRoutes}}
data-test-sbomComponentDetails-tab='{{item.id}}'
+ data-test-cy='sbom-component-details-tab-"{{item.id}}"'
>
{{item.label}}
diff --git a/app/components/sbom/component-details/summary/index.hbs b/app/components/sbom/component-details/summary/index.hbs
index 3a1c45a541..667c52e5e8 100644
--- a/app/components/sbom/component-details/summary/index.hbs
+++ b/app/components/sbom/component-details/summary/index.hbs
@@ -3,6 +3,7 @@
{{cs.label}}
diff --git a/app/components/sbom/component-status/index.hbs b/app/components/sbom/component-status/index.hbs
index 66f3268354..56f1edd66c 100644
--- a/app/components/sbom/component-status/index.hbs
+++ b/app/components/sbom/component-status/index.hbs
@@ -2,6 +2,7 @@
{{#each this.componentStatus as |status|}}
diff --git a/app/components/sbom/scan-details/component-list/dependency-type-header/index.hbs b/app/components/sbom/scan-details/component-list/dependency-type-header/index.hbs
index f2051b18e5..b9d87201f6 100644
--- a/app/components/sbom/scan-details/component-list/dependency-type-header/index.hbs
+++ b/app/components/sbom/scan-details/component-list/dependency-type-header/index.hbs
@@ -12,6 +12,7 @@
local-class='cursor-pointer'
{{on 'click' this.handleClick}}
data-test-sbom-scanDetails-dependencyTypeHeader-icon
+ data-test-cy='sbom-dependency-type-filter-icon'
/>
@@ -55,6 +57,7 @@
diff --git a/app/components/sbom/scan-details/component-list/type-header/index.hbs b/app/components/sbom/scan-details/component-list/type-header/index.hbs
index 2b166c408d..5d53c02f34 100644
--- a/app/components/sbom/scan-details/component-list/type-header/index.hbs
+++ b/app/components/sbom/scan-details/component-list/type-header/index.hbs
@@ -1,9 +1,16 @@
-
+
+
@@ -50,11 +59,13 @@
class='py-1 pl-2'
{{on 'click' (fn this.selectComponentType types.value)}}
data-test-sbom-scanDetails-componentTypeHeader-option
+ data-test-cy='component-type-filter-option'
>
{{/if}}
@@ -132,6 +133,7 @@
}}'
@underline='hover'
data-test-component-tree-nodeLabel
+ data-test-cy='component-tree-node-label'
>
{{node.label}}
diff --git a/app/components/sbom/scan-details/file-scan-summary/index.hbs b/app/components/sbom/scan-details/file-scan-summary/index.hbs
index b6b03edb5c..6c5e6a48db 100644
--- a/app/components/sbom/scan-details/file-scan-summary/index.hbs
+++ b/app/components/sbom/scan-details/file-scan-summary/index.hbs
@@ -3,6 +3,7 @@
@@ -93,6 +94,7 @@
{{on 'click' (perform this.handleGenerateReport @reportDetails.type)}}
@loading={{this.handleGenerateReport.isRunning}}
data-test-sbomReportList-reportGenerateBtn
+ data-test-cy='sbomReportList-reportGenerateBtn'
>
{{t 'generateReport'}}
diff --git a/app/components/sbom/summary-header/index.hbs b/app/components/sbom/summary-header/index.hbs
index 97ac731bee..cd2ba662ba 100644
--- a/app/components/sbom/summary-header/index.hbs
+++ b/app/components/sbom/summary-header/index.hbs
@@ -19,6 +19,7 @@
{{#if (has-block 'collapsibleContent')}}
>
+ */
+ getAppTableRows() {
+ return cy
+ .findAllByTestId(/^sbomApp-row-/, DEFAULT_ASSERT_OPTS)
+ .as('sbomAppRows');
+ }
+
+ /**
+ * @name checkAndWaitForAppLoadingView
+ * @description Checks and waits for the app loading view to be loaded
+ * @returns void
+ */
+ checkAndWaitForAppLoadingView() {
+ cy.findByTestId('sbomApp-emptyLoadingView-loading').should('exist');
+
+ cy.findByTestId(
+ 'sbomApp-emptyLoadingView-loading',
+ DEFAULT_ASSERT_OPTS
+ ).should('not.exist');
+ }
+
+ /**
+ * @name openPlatformFilter
+ * @description Opens the platform filter dropdown
+ * @returns void
+ */
+ openPlatformFilter() {
+ cy.findByTestId('sbom-platform-filterSelect')
+ .findByRole('combobox')
+ .should('exist')
+ .click({ force: true });
+ }
+
+ /**
+ * @name selectPlatform
+ * @description Selects the platform from the platform filter dropdown
+ * @param platform - The platform to select
+ * @returns void
+ */
+ selectPlatform(platform: string) {
+ return cy
+ .findByRole('option', { name: platform })
+ .should('be.visible')
+ .click({ force: true });
+ }
+
+ /**
+ * @name validateSbomAppRowsByPlatform
+ * @description Validates the app table rows by platform
+ * @param platform - The platform to validate
+ * @returns void
+ */
+ validateSbomAppRowsByPlatform(platform: 'android' | 'apple') {
+ return this.getAppTableRows()
+ .should('have.length.greaterThan', 0)
+ .each((row) => {
+ cy.wrap(row).within(() => {
+ cy.findByTestId(`app-platform-${platform}`).should('exist');
+ });
+ });
+ }
+
+ /**
+ * @name downloadSBOMAppReport
+ * @description Clicks on the download button for the specified report type (pdf or cyclonedx json file) in the past SBOM analyses view to trigger the download of the report.
+ * @param reportType - The type of report to download ('pdf' or 'cyclonedx_json_file')
+ */
+ downloadSBOMAppReport(reportType: 'pdf' | 'cyclonedx_json_file') {
+ cy.findAllByTestId(`sbomReportList-reportDownloadBtn-${reportType}`, {
+ timeout: 10000,
+ })
+ .should('be.visible')
+ .click({ force: true });
+ }
+
+ // ===== SBOM Detail Page — Actions =====
+
+ /**
+ * @name navigateToSbomDetailPage
+ * @description Navigates to the SBOM detail page by clicking on the app with the specified package name in the app table.
+ * @param packageName - The package name of the app to navigate to
+ */
+ navigateToSbomDetailPage(packageName: string) {
+ this.getAppTableRows()
+ .contains(new RegExp(packageName, 'i'))
+ .should('be.visible')
+ .click({ force: true });
+ }
+
+ /**
+ * @name validateOComponentDetailsOverviewCounts
+ * @description Validates overview counts using real values from sbomFileSummary API response
+ */
+ validateOComponentDetailsOverviewCounts(sbomFileSummaryAlias: string) {
+ cy.get(sbomFileSummaryAlias)
+ .its('response.body')
+ .then((componentCount) => {
+ const {
+ component_count,
+ machine_learning_model_count,
+ framework_count,
+ library_count,
+ file_count,
+ } = componentCount;
+
+ const fields = {
+ [cyTranslate('sbomModule.totalComponents')]: component_count,
+ [cyTranslate('sbomModule.mlModel')]: machine_learning_model_count,
+ [cyTranslate('library')]: library_count,
+ [cyTranslate('framework')]: framework_count,
+ [cyTranslate('file')]: file_count,
+ };
+
+ Object.keys(fields).forEach((key) => {
+ cy.findByTestId(`sbom-overview-item-${key}`)
+ .should('be.visible')
+ .should('contain', fields[key]);
+ });
+ });
+ }
+
+ /**
+ * @name validateMetadataDynamic
+ * @description Validates metadata fields using real values from sbomFileComponents API response
+ */
+ validateMetadataDynamic(sbomFileAlias: string = '@sbomFileComponents') {
+ cy.findByTestId('sbom-summary-header-collapsible-toggle-btn')
+ .should('be.visible')
+ .click();
+
+ cy.wait(sbomFileAlias, { timeout: 10000 })
+ .its('response.body')
+ .as('sbomFileData');
+
+ cy.get('@sbomFileData').then(
+ (sbomFile) => {
+ cy.findAllByTestId(/^sbom-scan-details-file-summary-group-/, {
+ timeout: 7000,
+ }).should('have.length.greaterThan', 0);
+
+ const metadataFields = {
+ [cyTranslate('status')]: sbomFile.status,
+ [cyTranslate('sbomModule.generatedDate')]: sbomFile.completed_at,
+ [cyTranslate('file')]: sbomFile.id,
+ };
+
+ Object.keys(metadataFields).forEach((label) => {
+ const expectedValue = metadataFields[label];
+
+ if (expectedValue) {
+ cy.findByTestId(`sbom-scan-details-file-summary-group-${label}`)
+ .should('be.visible')
+ .should('contain', expectedValue);
+ }
+ });
+ }
+ );
+ }
+
+ /**
+ * @name openComponentTypeFilter
+ */
+ openComponentTypeFilter() {
+ cy.findByTestId('sbom-component-type-filter-icon')
+ .should('be.visible')
+ .click({ force: true });
+
+ cy.findByTestId('sbom-component-type-filter-popover').should(
+ 'be.visible',
+ DEFAULT_ASSERT_OPTS
+ );
+ }
+
+ /**
+ * @name selectComponentType
+ */
+ selectComponentType(type: string) {
+ cy.findByTestId(`sbom-component-type-filter-radio-${type}`)
+ .should('exist', DEFAULT_ASSERT_OPTS)
+ .click({ force: true });
+ }
+
+ /**
+ * @name validateFilteredSBOMComponentRows
+ * @description Validates filtered rows contain the selected component type.
+ * Gracefully handles empty results — some types may have no components.
+ */
+ validateFilteredSBOMComponentRows(type: string) {
+ cy.get('body').then((body) => {
+ const rowCount = body.find('[data-test-cy="sbom-component-row"]').length;
+
+ if (rowCount > 0) {
+ cy.findAllByTestId('sbom-component-row')
+ .should('have.length.greaterThan', 0)
+ .each((row) => {
+ cy.wrap(row).invoke('text').should('contain', type);
+ });
+ }
+ });
+ }
+
+ /**
+ * @name clearComponentTypeFilterOnly
+ * @description Clears component type filter without selecting another type
+ */
+ clearComponentTypeFilterOnly() {
+ cy.findByTestId('sbom-component-type-clear-filter')
+ .should('exist')
+ .click({ force: true });
+
+ cy.get('body').click(0, 0);
+ }
+
+ /**
+ * @name openDependencyTypeFilter
+ * @description Opens the dependency type filter popover
+ */
+ openDependencyTypeFilter() {
+ cy.findByTestId('sbom-dependency-type-filter-icon')
+ .should('be.visible')
+ .click({ force: true });
+
+ cy.findByTestId('sbom-dependency-type-filter-popover').should(
+ 'be.visible',
+ DEFAULT_ASSERT_OPTS
+ );
+ }
+
+ /**
+ * @name selectDependencyType
+ */
+ selectDependencyType(type: string) {
+ cy.findByTestId(`sbom-dependency-type-filter-radio-${type}`)
+ .should('exist', DEFAULT_ASSERT_OPTS)
+ .click({ force: true });
+
+ cy.get('body').click(0, 0);
+ }
+
+ /**
+ * @name clearDependencyTypeFilterOnly
+ * @description Clears dependency type filter without selecting another type
+ */
+ clearDependencyTypeFilterOnly() {
+ cy.findByTestId('sbom-dependency-type-clear-filter')
+ .should('exist')
+ .click({ force: true });
+
+ cy.get('body').click(0, 0);
+ }
+}
diff --git a/cypress/support/Actions/common/UploadAppActions.ts b/cypress/support/Actions/common/UploadAppActions.ts
index 9c3ed7adba..395cef3676 100644
--- a/cypress/support/Actions/common/UploadAppActions.ts
+++ b/cypress/support/Actions/common/UploadAppActions.ts
@@ -110,7 +110,7 @@ export default class UploadAppActions {
cy.wrap(sbomProject).as('uploadedAppSBPrj');
if (sbomPrjFileID) {
- const sbFileURL = `${API_ROUTES.sbom.route}/${sbomPrjFileID}`;
+ const sbFileURL = `${API_ROUTES.sbomFileBase.route}/${sbomPrjFileID}`;
cy.intercept(sbFileURL).as('uploadedAppSBFileReq');
}
diff --git a/cypress/support/Mirage/factories.config.ts b/cypress/support/Mirage/factories.config.ts
index 7ae4cea762..c3d14660c0 100644
--- a/cypress/support/Mirage/factories.config.ts
+++ b/cypress/support/Mirage/factories.config.ts
@@ -51,6 +51,10 @@ import DeviceFactory, {
DEVICE_FACTORY_DEF,
} from 'irene/mirage/factories/device';
+import SbomScanSummaryFactory, {
+ SBOM_SCAN_SUMMARY_FACTORY_DEF,
+} from 'irene/mirage/factories/sbom-scan-summary';
+
// Extract factory method return values from a factory definition
export type FlattenFactoryMethods = {
[K in keyof T]: T[K] extends (n: number) => infer V ? V : T[K];
@@ -84,9 +88,14 @@ export interface MirageFactoryDefProps {
'available-manual-device': FlattenFactoryMethods;
'file-risk': FlattenFactoryMethods;
+ 'sbom-scan-summary': FlattenFactoryMethods<
+ typeof SBOM_SCAN_SUMMARY_FACTORY_DEF
+ >;
+
submission: FlattenFactoryMethods<
typeof SUBMISSION_FACTORY_DEF & {
file: number;
+ id: number;
}
>;
@@ -109,6 +118,7 @@ export interface MirageFactoryDefProps {
file: IncludeBaseFactoryProps<
typeof FILE_FACTORY_DEF & {
+ submission: number;
project: number;
executable_name: string;
analyses: Array;
@@ -137,6 +147,7 @@ const MIRAGE_FACTORIES: Record<
'sbom-project': SbomProjectFactory,
'available-manual-device': DeviceFactory,
'file-risk': FileRiskFactory,
+ 'sbom-scan-summary': SbomScanSummaryFactory,
};
export { MIRAGE_FACTORIES };
diff --git a/cypress/support/Websocket/index.ts b/cypress/support/Websocket/index.ts
index 0fec14f191..cd06edc93b 100644
--- a/cypress/support/Websocket/index.ts
+++ b/cypress/support/Websocket/index.ts
@@ -22,4 +22,17 @@ export type WS_MODEL_UPDATED_PAYLOAD_MAP = {
data: MirageFactoryDefProps['file-risk'];
};
};
+ submission: {
+ payload: {
+ model_name: 'submission';
+ data: MirageFactoryDefProps['submission'];
+ };
+ };
+
+ file: {
+ payload: {
+ model_name: 'file';
+ data: MirageFactoryDefProps['file'];
+ };
+ };
};
diff --git a/cypress/support/api.routes.ts b/cypress/support/api.routes.ts
index f507b44e54..fc73075a78 100644
--- a/cypress/support/api.routes.ts
+++ b/cypress/support/api.routes.ts
@@ -33,6 +33,20 @@ export const API_ROUTES = {
route: '/api/v2/sb_projects*',
alias: 'sbomProjectList',
},
+ sbomFileSummary: {
+ route: '/api/v2/sb_projects/*/sb_files/*/summary*',
+ alias: 'sbomFileSummary',
+ },
+
+ sbomFileComponents: {
+ route: '/api/v2/sb_files/*/sb_file_components*',
+ alias: 'sbomFileComponents',
+ },
+
+ sbomComponent: {
+ route: '/api/v2/sb_file_component/*',
+ alias: 'sbomComponent',
+ },
organizationList: {
route: '/api/organizations*',
alias: 'availableOrgsList',
@@ -69,7 +83,8 @@ export const API_ROUTES = {
// Single Record routes
file: { route: '/api/v3/files', alias: 'file' },
fileRisk: { route: '/api/v3/files/*/risk', alias: 'fileRisk' },
- sbom: { route: '/api/v2/sb_files', alias: 'sbomFile' },
+ sbomFile: { route: '/api/v2/sb_files/*', alias: 'sbomFile' },
+ sbomFileBase: { route: '/api/v2/sb_files', alias: 'sbomFileBase' },
unknownAnalysisStatus: {
route: '/api/profiles/*/unknown_analysis_status*',
alias: 'unknownAnalysisStatus',
@@ -162,4 +177,14 @@ export const API_ROUTES = {
route: '/api/v3/files/*/last_manual_dynamic_scan',
alias: 'dynamicscan',
},
+
+ // Download routes
+ sbomPdfDownload: {
+ route: '/api/v2/sb_reports/*/pdf/download_url',
+ alias: 'sbomPdfDownload',
+ },
+ sbCycloneDxJsonDownload: {
+ route: '/api/v2/sb_reports/*/cyclonedx_json_file/download_url',
+ alias: 'sbCycloneDxJsonDownload',
+ },
} as const;
diff --git a/cypress/tests/dynamic-scan.spec.ts b/cypress/tests/dynamic-scan.spec.ts
index ae41a208ba..6276afa19d 100644
--- a/cypress/tests/dynamic-scan.spec.ts
+++ b/cypress/tests/dynamic-scan.spec.ts
@@ -352,8 +352,8 @@ describe('Dynamic Scan', () => {
});
// Change items per page to 20
- cy.findByTestId(
- 'paginationItemPerPageOptions',
+ cy.findByLabelText(
+ 'pagination item per page options',
DEFAULT_ASSERT_OPTS
)
.findByRole('combobox')
diff --git a/cypress/tests/sbom.spec.ts b/cypress/tests/sbom.spec.ts
new file mode 100644
index 0000000000..231d4eb9ab
--- /dev/null
+++ b/cypress/tests/sbom.spec.ts
@@ -0,0 +1,523 @@
+import LoginActions from '../support/Actions/auth/LoginActions';
+import NetworkActions from '../support/Actions/common/NetworkActions';
+import { API_ROUTES } from '../support/api.routes';
+import { APPLICATION_ROUTES } from '../support/application.routes';
+import SbomPageActions from '../support/Actions/common/SbomPageActions';
+import UploadAppActions from '../support/Actions/common/UploadAppActions';
+import cyTranslate from '../support/translations';
+import { WS_MODEL_UPDATED_PAYLOAD_MAP } from '../support/Websocket';
+import { MirageFactoryDefProps } from '../support/Mirage';
+
+const loginActions = new LoginActions();
+const networkActions = new NetworkActions();
+const sbomPageActions = new SbomPageActions();
+
+const username = Cypress.env('TEST_USERNAME') || 'testuser';
+const password = Cypress.env('TEST_PASSWORD') || 'testpass';
+
+const NETWORK_WAIT_OPTS = {
+ timeout: 60000,
+};
+
+const SBOM_APP_SEARCH_QUERY = 'com.appknox.dvia';
+const SBOM_MFVA_APP_SEARCH_QUERY = 'com.appknox.mfva';
+
+Cypress.on('uncaught:exception', () => false);
+
+describe('SBOM Page', () => {
+ beforeEach(() => {
+ // Setup API intercepts
+ networkActions.hideNetworkLogsFor({ ...API_ROUTES.websockets });
+ cy.intercept(API_ROUTES.check.route).as('checkUserRoute');
+ cy.intercept(API_ROUTES.userInfo.route).as('userInfoRoute');
+ cy.intercept(API_ROUTES.sbomProjectList.route).as('sbomProjectList');
+ cy.intercept(API_ROUTES.submissionList.route).as('submissionList');
+
+ // Login and navigate
+ loginActions.loginWithCredAndSaveSession({ username, password });
+ cy.visit(APPLICATION_ROUTES.sbom);
+
+ // Wait for data
+ cy.wait('@submissionList', NETWORK_WAIT_OPTS);
+ cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS);
+
+ // Verify page loaded
+ cy.url().should('contain', APPLICATION_ROUTES.sbom);
+ cy.findByText(cyTranslate('sbomModule.sbomAppTitle')).should('exist');
+ cy.findByText(cyTranslate('sbomModule.sbomAppDescription')).should('exist');
+ });
+
+ describe('Platform Dropdown Filtering', () => {
+ beforeEach(() => {
+ sbomPageActions.openPlatformFilter();
+ });
+
+ it('shows only iOS apps when iOS selected', () => {
+ sbomPageActions.selectPlatform(cyTranslate('iOS'));
+ sbomPageActions.validateSbomAppRowsByPlatform('apple');
+ });
+
+ it('shows only Android apps when Android selected', () => {
+ sbomPageActions.selectPlatform(cyTranslate('android'));
+ sbomPageActions.validateSbomAppRowsByPlatform('android');
+ });
+ });
+
+ describe('Search Functionality', () => {
+ beforeEach(() => {
+ // Assert search input exists, is visible, and has the correct placeholder
+ cy.findByTestId('sbomApp-searchInput')
+ .should('exist')
+ .and('be.visible')
+ .and('have.attr', 'placeholder', cyTranslate('searchQuery'))
+ .as('searchInput');
+ });
+
+ it('should filter result by package name', () => {
+ cy.get('@searchInput').clear().type(SBOM_APP_SEARCH_QUERY);
+
+ // Simulate loading state
+ sbomPageActions.checkAndWaitForAppLoadingView();
+
+ sbomPageActions
+ .getAppTableRows()
+ .should('have.length.greaterThan', 0)
+ .each((row) => {
+ cy.wrap(row).within(() => {
+ cy.findByText(new RegExp(SBOM_APP_SEARCH_QUERY, 'i')).should(
+ 'exist'
+ );
+ });
+ });
+ });
+
+ it('should restore results when search is cleared', () => {
+ cy.get('@searchInput').type(SBOM_APP_SEARCH_QUERY);
+
+ sbomPageActions.checkAndWaitForAppLoadingView();
+
+ // Get intercepted request length
+ cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS)
+ .its('response.body.count')
+ .as('initialApiRequestCount');
+
+ sbomPageActions.getAppTableRows().its('length').as('initialAppTableRows');
+
+ // Clear search input
+ cy.get('@searchInput').clear();
+
+ sbomPageActions.checkAndWaitForAppLoadingView();
+
+ cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS)
+ .its('response.body.count')
+ .as('newApiRequestCount');
+
+ sbomPageActions.getAppTableRows().its('length').as('newAppTableRows');
+
+ // Check if new app table rows are greater than initial app table rows
+ cy.get('@initialAppTableRows').then((initialAppTableRows) => {
+ cy.get('@newAppTableRows').should('be.gte', initialAppTableRows);
+ });
+
+ // Check network request count is greater than initial app table rows
+ cy.get('@initialApiRequestCount').then((initialApiRequestCount) => {
+ cy.get('@newApiRequestCount').should(
+ 'be.greaterThan',
+ initialApiRequestCount
+ );
+ });
+ });
+
+ it('should show empty state when search has no results', () => {
+ const INVALID_APP_SEARCH_QUERY = 'this-app-does-not-exist-123';
+
+ cy.get('@searchInput').type(INVALID_APP_SEARCH_QUERY);
+
+ sbomPageActions.checkAndWaitForAppLoadingView();
+
+ cy.findByTestId('sbomApp-table').should('not.exist');
+
+ cy.findByTestId('sbomApp-emptyTextContainer')
+ .should('be.visible')
+ .within(() => {
+ cy.findByText(
+ cyTranslate('sbomModule.sbomAppEmptyText.title')
+ ).should('exist');
+
+ cy.findByText(
+ cyTranslate('sbomModule.sbomAppEmptyText.description')
+ ).should('exist');
+ });
+ });
+ });
+
+ describe('SBOM Report Flow', () => {
+ beforeEach(() => {
+ cy.intercept('GET', API_ROUTES.uploadApp.route).as('uploadAppReq');
+ cy.intercept('POST', API_ROUTES.uploadApp.route).as('uploadAppReqPOST');
+ });
+
+ const uploadAppActions = new UploadAppActions();
+
+ it('opens Past SBOM Analyses and validates report generation flow', () => {
+ cy.intercept('GET', API_ROUTES.sbomPdfDownload.route).as('sbPdfDownload');
+
+ cy.intercept('GET', API_ROUTES.sbCycloneDxJsonDownload.route).as(
+ 'sbCycloneDxJsonDownload'
+ );
+
+ uploadAppActions.initiateViaSystemUpload('MFVA.apk');
+
+ let uploadSubmissionID: number | null = null;
+
+ // Intercept submission model updated event and get the uploaded file ID
+ cy.interceptWsMessage<
+ | WS_MODEL_UPDATED_PAYLOAD_MAP['submission']['payload']
+ | WS_MODEL_UPDATED_PAYLOAD_MAP['file']['payload']
+ >((event, payload) => {
+ if (
+ event === 'model_updated' &&
+ payload?.model_name === 'submission' &&
+ payload?.data?.status === 7 &&
+ payload?.data?.file !== null
+ ) {
+ uploadSubmissionID = payload.data.id;
+ }
+
+ if (
+ uploadSubmissionID &&
+ payload?.model_name === 'file' &&
+ payload?.data?.submission === uploadSubmissionID
+ ) {
+ cy.wrap(payload?.data).as('uploadedFile');
+ cy.wrap(payload?.data?.project).as('uploadedAppPrjID');
+
+ return true;
+ }
+
+ return false;
+ });
+
+ // Wait for the uploaded file details to be available from the websocket intercept
+ cy.reload();
+
+ cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS);
+
+ // Find the uploaded file in the SBOM project list
+ cy.get('@sbomProjectList')
+ .its('response.body.results')
+ .then((results: Array) => {
+ cy.get('@uploadedAppPrjID').then((uploadedAppPrjID) => {
+ const sbomProjectRow = results.find(
+ (r) => r.project === uploadedAppPrjID
+ );
+
+ if (sbomProjectRow) {
+ cy.wrap(sbomProjectRow.id).as('uploadedAppSBPrjId');
+ }
+ });
+ });
+
+ cy.get('@uploadedAppSBPrjId').then((sbPrjID) => {
+ cy.findByTestId(`sbomApp-row-${sbPrjID}`)
+ .findByTestId('sbomApp-actionBtn')
+ .click({ force: true });
+ });
+
+ // Validate Past SBOM Analyses page
+ const pastAnalysesText = cyTranslate('sbomModule.pastSbomAnalyses');
+
+ cy.findByLabelText('sbom app action menu item')
+ .contains(pastAnalysesText)
+ .should('be.visible')
+ .click({ force: true });
+
+ cy.findAllByTestId('sbomScan-row').should('have.length.greaterThan', 0);
+ cy.findByText(pastAnalysesText).should('be.visible');
+
+ // Wait for SBOM Scan to complete
+ cy.wait(5000);
+
+ // open the first (latest) report in the list
+ cy.findAllByTestId('sbomScan-viewReportBtn', { timeout: 10000 })
+ .first()
+ .should('be.visible')
+ .click({ force: true });
+
+ cy.findAllByTestId('sbomReportList-reportGenerateBtn')
+ .should('not.be.disabled')
+ .click({ force: true });
+
+ cy.findByText(
+ cyTranslate('fileReport.detailedReport', { reportType: 'pdf' }),
+ NETWORK_WAIT_OPTS
+ ).should('be.visible');
+
+ cy.findByText(
+ cyTranslate('sbomModule.sbomDownloadPdfPrimaryText')
+ ).should('be.visible');
+
+ // Validate report is generated successfully
+ cy.findByTestId('sbomReportList-loading').should('not.exist');
+ cy.findByTestId('sbomReportList-reportGenerateBtn').should('not.exist');
+
+ cy.findAllByTestId(/sbomReportList-reportDownloadBtn/).should(
+ 'have.length.at.least',
+ 2
+ );
+
+ // Download PDF report
+ sbomPageActions.downloadSBOMAppReport('pdf');
+
+ cy.wait('@sbPdfDownload', { timeout: 30000 })
+ .its('response.statusCode')
+ .should('equal', 200);
+
+ // Download JSON
+ sbomPageActions.downloadSBOMAppReport('cyclonedx_json_file');
+
+ cy.wait('@sbCycloneDxJsonDownload', { timeout: 30000 })
+ .its('response.statusCode')
+ .should('equal', 200);
+ });
+ });
+
+ describe('SBOM Report Detail Page', () => {
+ beforeEach(() => {
+ cy.intercept(API_ROUTES.sbomFileSummary.route).as('sbomFileSummary');
+
+ cy.intercept(API_ROUTES.sbomFileComponents.route).as(
+ 'sbomFileComponents'
+ );
+
+ cy.intercept(API_ROUTES.sbomComponent.route).as('sbomComponent');
+
+ sbomPageActions.navigateToSbomDetailPage(SBOM_MFVA_APP_SEARCH_QUERY);
+
+ cy.wait('@sbomFileSummary', NETWORK_WAIT_OPTS);
+ });
+
+ it('validates overview counts from API response', () => {
+ sbomPageActions.validateOComponentDetailsOverviewCounts(
+ '@sbomFileSummary'
+ );
+ });
+
+ it('validates metadata section from API response', () => {
+ sbomPageActions.validateMetadataDynamic('@sbomFileComponents');
+ });
+
+ it('validates default tree view state on page load', () => {
+ // Tree nodes are rendered
+ sbomPageActions.componentLinks().should('have.length.greaterThan', 0);
+
+ // collapse all button is visible but disabled on initial load since all nodes are already collapsed
+ sbomPageActions.collapseAllBtn().should('be.disabled');
+ });
+
+ describe('List View - Filters', () => {
+ beforeEach(() => {
+ // Switch to list view and wait for component data
+ cy.findByTestId('sbom-scan-details-list-view-btn').click();
+ cy.wait('@sbomFileComponents', NETWORK_WAIT_OPTS);
+ });
+
+ it('filters rows by each component type', () => {
+ const componentTypes = [
+ cyTranslate('library'),
+ cyTranslate('framework'),
+ cyTranslate('file'),
+ cyTranslate('sbomModule.mlModel'),
+ ];
+
+ componentTypes.forEach((type) => {
+ sbomPageActions.openComponentTypeFilter();
+ sbomPageActions.selectComponentType(type);
+
+ cy.get('body').click(0, 0);
+
+ sbomPageActions.validateFilteredSBOMComponentRows(type);
+ sbomPageActions.openComponentTypeFilter();
+ sbomPageActions.clearComponentTypeFilterOnly();
+ });
+ });
+
+ it('filters rows by each dependency type', () => {
+ const dependencyTypes = ['Direct', 'Transitive'] as const;
+
+ dependencyTypes.forEach((type) => {
+ sbomPageActions.openDependencyTypeFilter();
+ sbomPageActions.selectDependencyType(type);
+
+ cy.get('body').click(0, 0);
+
+ sbomPageActions.validateFilteredSBOMComponentRows(type);
+ sbomPageActions.openDependencyTypeFilter();
+ sbomPageActions.clearDependencyTypeFilterOnly();
+ });
+ });
+ });
+
+ describe('Tree View - Expand & Component Details', () => {
+ it('expands a tree node and enables collapse all, then collapses all', () => {
+ sbomPageActions.expandNodeIcon().should('be.visible').click();
+
+ // Node expanded — collapse all should now be enabled
+ sbomPageActions.collapseAllBtn().should('not.be.disabled');
+ sbomPageActions.collapseAllBtn().click({ force: true });
+
+ // All collapsed — back to disabled
+ sbomPageActions.collapseAllBtn().should('be.disabled');
+ });
+
+ it('opens component details, validates tabs and dynamic status, navigates back via breadcrumb', () => {
+ sbomPageActions.expandNodeIcon().should('be.visible').click();
+ sbomPageActions.componentLinks().first().click({ force: true });
+
+ // Wait for component API before asserting
+ cy.wait('@sbomComponent', NETWORK_WAIT_OPTS);
+
+ // Tabs visible
+ // HBS: data-test-sbomComponentDetails-tab='{{item.id}}'
+ (['overview', 'vulnerabilities'] as const).forEach((tabId) => {
+ sbomPageActions.componentDetailsTab(tabId).should('be.visible');
+ });
+
+ // Summary fields visible
+ const summaryFieldLabels = [
+ 'sbomModule.componentType',
+ 'dependencyType',
+ 'status',
+ ] as const;
+
+ summaryFieldLabels.forEach((tabId) => {
+ sbomPageActions
+ .componentSummary(cyTranslate(tabId))
+ .should('be.visible');
+ });
+
+ sbomPageActions
+ .componentStatus(cyTranslate('chipStatus.secure'))
+ .should('exist');
+
+ // Navigate back via breadcrumb
+ cy.findByText(
+ new RegExp(
+ cyTranslate('sbomModule.allComponentsAndVulnerabilities'),
+ 'i'
+ )
+ )
+ .should('be.visible')
+ .click();
+
+ // Back on detail page — tree view button confirms correct page
+ cy.findByTestId('sbom-scan-details-tree-view-btn').should('be.visible');
+ });
+ });
+ });
+
+ describe('SBOM Page - Pagination Tests', () => {
+ beforeEach(() => {
+ cy.findByLabelText('pagination previous button').as('prevBtn');
+ cy.findByLabelText('pagination next button').as('nextBtn');
+
+ sbomPageActions.getAppTableRows().first().as('firstRow');
+ });
+
+ describe('Navigation - Next/Previous', () => {
+ it('should navigate to next page and verify data changed', () => {
+ cy.get('@firstRow').invoke('text').as('initialFirstRow');
+ cy.get('@nextBtn').should('not.be.disabled').click();
+ cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS);
+
+ sbomPageActions.getAppTableRows().should('have.length.greaterThan', 0);
+
+ cy.get('@sbomProjectList')
+ .its('response.body.count')
+ .then((totalCount) => {
+ cy.findByLabelText('pagination page range')
+ .invoke('text')
+ .should((text) => {
+ const normalized = text.replace(/\s+/g, ' ').trim();
+ expect(normalized).to.match(/^\d+-\d+/);
+ expect(normalized).to.include(`of ${totalCount} Apps`);
+ });
+ });
+
+ cy.get('@firstRow')
+ .invoke('text')
+ .then((newFirstRow) => {
+ cy.get('@initialFirstRow').should('not.equal', newFirstRow);
+ });
+
+ cy.get('@prevBtn').should('not.be.disabled');
+ });
+
+ it('should disable Previous button on first page', () => {
+ cy.get('@prevBtn').should('be.disabled');
+ });
+ });
+
+ describe('Button States', () => {
+ it('should enable/disable buttons correctly during navigation', () => {
+ cy.get('@prevBtn').should('be.disabled');
+ cy.get('@nextBtn').should('not.be.disabled').click();
+
+ cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS);
+
+ sbomPageActions.getAppTableRows().should('have.length.greaterThan', 0);
+
+ cy.get('@prevBtn').should('not.be.disabled');
+ });
+ });
+
+ [25, 50].forEach((itemsPerPage) => {
+ describe('Items Per Page', () => {
+ it(`should update rows and URL when items per page is changed to ${itemsPerPage}`, () => {
+ cy.findByLabelText('pagination item per page options').click();
+ cy.findByRole('option', { name: `${itemsPerPage}` }).click();
+
+ cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS);
+
+ cy.url().should('include', `app_limit=${itemsPerPage}`);
+
+ sbomPageActions
+ .getAppTableRows()
+ .should('have.length.greaterThan', 0);
+
+ cy.get('@sbomProjectList')
+ .its('response.body.count')
+ .then((totalCount) => {
+ const expectedEnd = Math.min(itemsPerPage, totalCount);
+
+ cy.findByLabelText('pagination page range')
+ .should('exist')
+ .invoke('text')
+ .should((text) => {
+ const normalized = text.replace(/\s+/g, ' ').trim();
+
+ expect(normalized).to.match(new RegExp(`^1-${expectedEnd}`));
+ expect(normalized).to.include(`of ${totalCount} Apps`);
+ });
+ });
+ });
+ });
+ });
+
+ describe('URL State', () => {
+ it('should update URL when changing pages', () => {
+ cy.url().then((page1Url) => {
+ cy.get('@nextBtn').click();
+
+ cy.wait('@sbomProjectList', NETWORK_WAIT_OPTS);
+
+ cy.url().should('not.equal', page1Url);
+
+ sbomPageActions
+ .getAppTableRows()
+ .should('have.length.greaterThan', 0);
+ });
+ });
+ });
+ });
+});
diff --git a/mirage/factories/sbom-scan-summary.ts b/mirage/factories/sbom-scan-summary.ts
index 916671e8c1..1ec81678c5 100644
--- a/mirage/factories/sbom-scan-summary.ts
+++ b/mirage/factories/sbom-scan-summary.ts
@@ -1,11 +1,13 @@
import { Factory } from 'miragejs';
import { faker } from '@faker-js/faker';
-export default Factory.extend({
+export const SBOM_SCAN_SUMMARY_FACTORY_DEF = {
component_count: faker.number.int(1000),
library_count: faker.number.int(125),
framework_count: faker.number.int(125),
application_count: faker.number.int(125),
file_count: faker.number.int(125),
machine_learning_model_count: faker.number.int(125),
-});
+};
+
+export default Factory.extend(SBOM_SCAN_SUMMARY_FACTORY_DEF);