// ----------------------------------------------------------------------------- // sbom-evidence.e2e.spec.ts // Sprint: SPRINT_20260107_005_004_FE // Task: UI-012 — E2E Tests for CycloneDX evidence and pedigree UI components // ----------------------------------------------------------------------------- import { test, expect } from '@playwright/test'; /** * E2E tests for SBOM Evidence and Pedigree UI components. * * Tests cover: * - Evidence panel interaction * - Pedigree timeline click-through * - Diff viewer expand/collapse * - Keyboard navigation * * Test data: Uses mock API responses intercepted via Playwright's route handler. */ test.describe('SBOM Evidence Components E2E', () => { // Mock data for tests const mockEvidence = { identity: { field: 'purl', confidence: 0.95, methods: [ { technique: 'manifest-analysis', confidence: 0.95, value: 'package.json:42', }, { technique: 'hash-comparison', confidence: 0.90, value: 'sha256:abc123...', }, ], }, occurrences: [ { location: '/node_modules/lodash/index.js', line: 1 }, { location: '/node_modules/lodash/lodash.min.js' }, { location: '/node_modules/lodash/package.json', line: 42 }, ], licenses: [ { license: { id: 'MIT' }, acknowledgement: 'declared' }, ], copyright: [ { text: 'Copyright (c) JS Foundation and contributors' }, ], }; const mockPedigree = { ancestors: [ { type: 'library', name: 'openssl', version: '1.1.1n', purl: 'pkg:generic/openssl@1.1.1n', }, ], variants: [ { type: 'library', name: 'openssl', version: '1.1.1n-0+deb11u5', purl: 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5', }, ], commits: [ { uid: 'abc123def456789', url: 'https://github.com/openssl/openssl/commit/abc123def456789', author: { name: 'John Doe', email: 'john@example.com', timestamp: '2024-01-15T10:30:00Z', }, message: 'Fix buffer overflow in SSL handshake\n\nThis commit addresses CVE-2024-1234.', }, ], patches: [ { type: 'backport', diff: { url: 'https://github.com/openssl/openssl/commit/abc123.patch', text: '--- a/ssl/ssl_lib.c\n+++ b/ssl/ssl_lib.c\n@@ -100,7 +100,7 @@\n- buffer[size] = data;\n+ if (size < MAX_SIZE) buffer[size] = data;', }, resolves: [ { id: 'CVE-2024-1234', type: 'security', name: 'Buffer overflow vulnerability' }, ], }, { type: 'cherry-pick', resolves: [ { id: 'CVE-2024-5678', type: 'security' }, ], }, ], }; test.beforeEach(async ({ page }) => { // Intercept API calls and return mock data await page.route('**/api/sbom/evidence/**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockEvidence), }); }); await page.route('**/api/sbom/pedigree/**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockPedigree), }); }); // Navigate to component detail page await page.goto('/sbom/components/pkg:deb/debian/openssl@1.1.1n-0+deb11u5'); await page.waitForSelector('.component-detail-page'); }); // ------------------------------------------------------------------------- // Evidence Panel Tests // ------------------------------------------------------------------------- test.describe('Evidence Panel', () => { test('should display evidence panel with identity section', async ({ page }) => { const panel = page.locator('.cdx-evidence-panel'); await expect(panel).toBeVisible(); const identitySection = panel.locator('.evidence-section--identity'); await expect(identitySection).toBeVisible(); }); test('should display confidence badge with correct tier', async ({ page }) => { const badge = page.locator('.evidence-confidence-badge').first(); await expect(badge).toBeVisible(); // Should show green for 95% confidence (Tier 1) await expect(badge).toHaveClass(/tier-1/); }); test('should display occurrence count', async ({ page }) => { const occurrenceHeader = page.locator('.evidence-section__title').filter({ hasText: 'Occurrences' }); await expect(occurrenceHeader).toContainText('(3)'); }); test('should list all occurrences', async ({ page }) => { const occurrences = page.locator('.occurrence-item'); await expect(occurrences).toHaveCount(3); }); test('should display license information', async ({ page }) => { const licenseSection = page.locator('.evidence-section--licenses'); await expect(licenseSection).toBeVisible(); await expect(licenseSection).toContainText('MIT'); }); test('should display copyright information', async ({ page }) => { const copyrightSection = page.locator('.evidence-section--copyright'); await expect(copyrightSection).toContainText('JS Foundation'); }); test('should collapse/expand sections on click', async ({ page }) => { // Find a collapsible section header const identityHeader = page.locator('.evidence-section__header').first(); const identityContent = page.locator('.evidence-section__content').first(); // Should be expanded by default await expect(identityContent).toBeVisible(); // Click to collapse await identityHeader.click(); await expect(identityContent).not.toBeVisible(); // Click to expand await identityHeader.click(); await expect(identityContent).toBeVisible(); }); test('should open evidence drawer on occurrence click', async ({ page }) => { const occurrence = page.locator('.occurrence-item').first(); await occurrence.click(); const drawer = page.locator('.evidence-detail-drawer'); await expect(drawer).toBeVisible(); }); }); // ------------------------------------------------------------------------- // Evidence Detail Drawer Tests // ------------------------------------------------------------------------- test.describe('Evidence Detail Drawer', () => { test.beforeEach(async ({ page }) => { // Open the drawer by clicking an occurrence await page.locator('.occurrence-item').first().click(); await page.waitForSelector('.evidence-detail-drawer'); }); test('should display detection method chain', async ({ page }) => { const methodChain = page.locator('.method-chain'); await expect(methodChain).toBeVisible(); const methods = page.locator('.method-chain__item'); await expect(methods).toHaveCount(2); }); test('should display technique labels correctly', async ({ page }) => { const techniques = page.locator('.method-chain__technique'); await expect(techniques.first()).toContainText('Manifest Analysis'); }); test('should close on escape key', async ({ page }) => { const drawer = page.locator('.evidence-detail-drawer'); await expect(drawer).toBeVisible(); await page.keyboard.press('Escape'); await expect(drawer).not.toBeVisible(); }); test('should close on backdrop click', async ({ page }) => { const overlay = page.locator('.drawer-overlay'); await overlay.click({ position: { x: 10, y: 10 } }); // Click on overlay, not drawer await expect(page.locator('.evidence-detail-drawer')).not.toBeVisible(); }); test('should copy evidence JSON to clipboard', async ({ page }) => { const copyBtn = page.locator('.reference-card .copy-btn'); await copyBtn.click(); await expect(copyBtn).toContainText('Copied!'); // Verify clipboard content const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); expect(clipboardText).toContain('"field": "purl"'); }); }); // ------------------------------------------------------------------------- // Pedigree Timeline Tests // ------------------------------------------------------------------------- test.describe('Pedigree Timeline', () => { test('should display pedigree timeline', async ({ page }) => { const timeline = page.locator('.pedigree-timeline'); await expect(timeline).toBeVisible(); }); test('should show all timeline nodes', async ({ page }) => { const nodes = page.locator('.timeline-node'); await expect(nodes).toHaveCount(3); // ancestor + variant + current }); test('should display stage labels', async ({ page }) => { const stages = page.locator('.timeline-stage'); await expect(stages.filter({ hasText: 'Upstream' })).toBeVisible(); await expect(stages.filter({ hasText: 'Distro' })).toBeVisible(); await expect(stages.filter({ hasText: 'Local' })).toBeVisible(); }); test('should highlight current node', async ({ page }) => { const currentNode = page.locator('.timeline-node--current'); await expect(currentNode).toBeVisible(); await expect(currentNode).toHaveClass(/highlighted/); }); test('should show version differences', async ({ page }) => { const ancestorVersion = page.locator('.timeline-node--ancestor .timeline-node__version'); const variantVersion = page.locator('.timeline-node--variant .timeline-node__version'); await expect(ancestorVersion).toContainText('1.1.1n'); await expect(variantVersion).toContainText('1.1.1n-0+deb11u5'); }); test('should emit event on node click', async ({ page }) => { const ancestorNode = page.locator('.timeline-node--ancestor'); await ancestorNode.click(); // Should show some detail or navigation // (implementation-specific behavior) await expect(ancestorNode).toHaveClass(/selected/); }); }); // ------------------------------------------------------------------------- // Patch List Tests // ------------------------------------------------------------------------- test.describe('Patch List', () => { test('should display patch count in header', async ({ page }) => { const header = page.locator('.patch-list__title'); await expect(header).toContainText('Patches Applied (2)'); }); test('should show patch type badges', async ({ page }) => { const backportBadge = page.locator('.patch-badge--backport'); const cherryPickBadge = page.locator('.patch-badge--cherry-pick'); await expect(backportBadge).toBeVisible(); await expect(cherryPickBadge).toBeVisible(); }); test('should display CVE tags', async ({ page }) => { const cveTags = page.locator('.cve-tag'); await expect(cveTags.filter({ hasText: 'CVE-2024-1234' })).toBeVisible(); }); test('should show confidence badges', async ({ page }) => { const badges = page.locator('.patch-item .evidence-confidence-badge'); await expect(badges).toHaveCount(2); }); test('should expand patch details on click', async ({ page }) => { const expandBtn = page.locator('.patch-expand-btn').first(); await expandBtn.click(); const details = page.locator('.patch-item__details').first(); await expect(details).toBeVisible(); }); test('should show resolved issues when expanded', async ({ page }) => { const expandBtn = page.locator('.patch-expand-btn').first(); await expandBtn.click(); const resolvedList = page.locator('.resolved-list').first(); await expect(resolvedList).toBeVisible(); await expect(resolvedList).toContainText('CVE-2024-1234'); }); }); // ------------------------------------------------------------------------- // Diff Viewer Tests // ------------------------------------------------------------------------- test.describe('Diff Viewer', () => { test.beforeEach(async ({ page }) => { // Open diff viewer by clicking View Diff button const viewDiffBtn = page.locator('.patch-action-btn').first(); await viewDiffBtn.click(); await page.waitForSelector('.diff-viewer'); }); test('should display diff viewer modal', async ({ page }) => { const viewer = page.locator('.diff-viewer'); await expect(viewer).toBeVisible(); }); test('should show unified view by default', async ({ page }) => { const unifiedBtn = page.locator('.view-mode-btn').filter({ hasText: 'Unified' }); await expect(unifiedBtn).toHaveClass(/active/); }); test('should switch to side-by-side view', async ({ page }) => { const sideBySideBtn = page.locator('.view-mode-btn').filter({ hasText: 'Side-by-Side' }); await sideBySideBtn.click(); await expect(sideBySideBtn).toHaveClass(/active/); const sideBySideContainer = page.locator('.diff-side-by-side'); await expect(sideBySideContainer).toBeVisible(); }); test('should display line numbers', async ({ page }) => { const lineNumbers = page.locator('.line-number'); await expect(lineNumbers.first()).toBeVisible(); }); test('should highlight additions in green', async ({ page }) => { const additions = page.locator('.diff-line--addition'); await expect(additions).toBeVisible(); }); test('should highlight deletions in red', async ({ page }) => { const deletions = page.locator('.diff-line--deletion'); await expect(deletions).toBeVisible(); }); test('should copy diff on button click', async ({ page }) => { const copyBtn = page.locator('.copy-diff-btn'); await copyBtn.click(); await expect(copyBtn).toContainText('Copied!'); }); test('should close diff viewer on close button', async ({ page }) => { const closeBtn = page.locator('.diff-viewer .close-btn'); await closeBtn.click(); await expect(page.locator('.diff-viewer')).not.toBeVisible(); }); test('should close diff viewer on escape key', async ({ page }) => { await page.keyboard.press('Escape'); await expect(page.locator('.diff-viewer')).not.toBeVisible(); }); }); // ------------------------------------------------------------------------- // Commit Info Tests // ------------------------------------------------------------------------- test.describe('Commit Info', () => { test('should display commit section', async ({ page }) => { const commitSection = page.locator('.commits-list'); await expect(commitSection).toBeVisible(); }); test('should show short SHA', async ({ page }) => { const sha = page.locator('.commit-sha__value'); await expect(sha).toContainText('abc123d'); // First 7 chars }); test('should copy full SHA on click', async ({ page }) => { const copyBtn = page.locator('.commit-sha__copy'); await copyBtn.click(); const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); expect(clipboardText).toBe('abc123def456789'); }); test('should link to upstream repository', async ({ page }) => { const link = page.locator('.commit-sha__link'); await expect(link).toHaveAttribute('href', 'https://github.com/openssl/openssl/commit/abc123def456789'); }); test('should display author information', async ({ page }) => { const author = page.locator('.commit-identity__name'); await expect(author.first()).toContainText('John Doe'); }); test('should expand truncated commit message', async ({ page }) => { // If message is truncated, there should be an expand button const messageContainer = page.locator('.commit-message'); const expandBtn = messageContainer.locator('.expand-btn'); if (await expandBtn.isVisible()) { const truncatedMessage = await messageContainer.locator('.message-content').textContent(); await expandBtn.click(); const fullMessage = await messageContainer.locator('.message-content').textContent(); expect(fullMessage?.length).toBeGreaterThan(truncatedMessage?.length ?? 0); } }); }); // ------------------------------------------------------------------------- // Keyboard Navigation Tests // ------------------------------------------------------------------------- test.describe('Keyboard Navigation', () => { test('should navigate evidence sections with Tab', async ({ page }) => { // Focus the first focusable element in evidence panel await page.locator('.cdx-evidence-panel').locator('button').first().focus(); // Tab through sections await page.keyboard.press('Tab'); const focusedElement = page.locator(':focus'); await expect(focusedElement).toBeVisible(); }); test('should navigate timeline nodes with arrow keys', async ({ page }) => { // Focus the timeline await page.locator('.timeline-node').first().focus(); // Arrow right to next node await page.keyboard.press('ArrowRight'); const focused = page.locator('.timeline-node:focus'); await expect(focused).toHaveClass(/variant|current/); }); test('should expand/collapse patch with Enter', async ({ page }) => { const expandBtn = page.locator('.patch-expand-btn').first(); await expandBtn.focus(); await page.keyboard.press('Enter'); const details = page.locator('.patch-item__details').first(); await expect(details).toBeVisible(); await page.keyboard.press('Enter'); await expect(details).not.toBeVisible(); }); test('should support screen reader announcements', async ({ page }) => { // Verify ARIA attributes are present const panel = page.locator('.cdx-evidence-panel'); await expect(panel).toHaveAttribute('aria-label', /Evidence/); const timeline = page.locator('.pedigree-timeline'); await expect(timeline).toHaveAttribute('aria-label', /Pedigree/); }); }); // ------------------------------------------------------------------------- // Empty State Tests // ------------------------------------------------------------------------- test.describe('Empty States', () => { test('should show empty state when no evidence', async ({ page }) => { // Override route to return empty evidence await page.route('**/api/sbom/evidence/**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}), }); }); await page.goto('/sbom/components/pkg:npm/empty-pkg@1.0.0'); await page.waitForSelector('.component-detail-page'); const emptyState = page.locator('.empty-state'); await expect(emptyState).toBeVisible(); await expect(emptyState).toContainText('No Evidence Data'); }); test('should show empty state when no pedigree', async ({ page }) => { await page.route('**/api/sbom/pedigree/**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}), }); }); await page.goto('/sbom/components/pkg:npm/no-pedigree@1.0.0'); await page.waitForSelector('.component-detail-page'); // Should not show pedigree timeline const timeline = page.locator('.pedigree-timeline'); await expect(timeline).not.toBeVisible(); }); test('should show empty patch list message', async ({ page }) => { await page.route('**/api/sbom/pedigree/**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ patches: [] }), }); }); await page.goto('/sbom/components/pkg:npm/no-patches@1.0.0'); await page.waitForSelector('.component-detail-page'); const emptyPatchMsg = page.locator('.patch-list__empty'); await expect(emptyPatchMsg).toBeVisible(); }); }); // ------------------------------------------------------------------------- // Error Handling Tests // ------------------------------------------------------------------------- test.describe('Error Handling', () => { test('should display error state on API failure', async ({ page }) => { await page.route('**/api/sbom/evidence/**', async (route) => { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Internal server error' }), }); }); await page.goto('/sbom/components/pkg:npm/error-pkg@1.0.0'); await page.waitForSelector('.component-detail-page'); const errorState = page.locator('.error-state'); await expect(errorState).toBeVisible(); }); test('should provide retry button on error', async ({ page }) => { let callCount = 0; await page.route('**/api/sbom/evidence/**', async (route) => { callCount++; if (callCount === 1) { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Internal server error' }), }); } else { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockEvidence), }); } }); await page.goto('/sbom/components/pkg:npm/retry-pkg@1.0.0'); await page.waitForSelector('.error-state'); const retryBtn = page.locator('.retry-btn'); await retryBtn.click(); // Should now show evidence panel await expect(page.locator('.cdx-evidence-panel')).toBeVisible(); }); test('should handle network timeout gracefully', async ({ page }) => { await page.route('**/api/sbom/evidence/**', async (route) => { await new Promise((resolve) => setTimeout(resolve, 10000)); // Simulate timeout await route.abort('timedout'); }); // Set shorter timeout for the page await page.goto('/sbom/components/pkg:npm/timeout-pkg@1.0.0', { timeout: 5000 }).catch(() => { // Expected to timeout }); // Should show loading or error state const loadingOrError = page.locator('.loading-state, .error-state'); await expect(loadingOrError).toBeVisible(); }); }); });