611 lines
22 KiB
TypeScript
611 lines
22 KiB
TypeScript
// -----------------------------------------------------------------------------
|
|
// 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();
|
|
});
|
|
});
|
|
});
|