sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

@@ -193,11 +193,203 @@ test.describe('Evidence Panel E2E', () => {
test('should expand attestation chain node on click', async ({ page }) => {
const node = page.locator('.chain-node').first();
await node.click();
await expect(node).toHaveClass(/chain-node--expanded/);
});
});
test.describe('Reachability tab', () => {
test.beforeEach(async ({ page }) => {
// Switch to Reachability tab
await page.click('[aria-controls="panel-reachability"]');
await page.waitForSelector('.reachability-tab');
});
test('should display status badge with correct state', async ({ page }) => {
const badge = page.locator('.status-badge');
await expect(badge).toBeVisible();
// Badge should have one of the status classes
const hasStatusClass = await badge.evaluate((el) =>
el.classList.contains('status-badge--reachable') ||
el.classList.contains('status-badge--unreachable') ||
el.classList.contains('status-badge--partial') ||
el.classList.contains('status-badge--unknown')
);
expect(hasStatusClass).toBe(true);
});
test('should display confidence percentage', async ({ page }) => {
const confidence = page.locator('.confidence-value');
await expect(confidence).toBeVisible();
// Should match percentage format (e.g., "85%")
await expect(confidence).toHaveText(/%$/);
});
test('should have View Full Graph button', async ({ page }) => {
const viewBtn = page.locator('.view-graph-btn');
await expect(viewBtn).toBeVisible();
await expect(viewBtn).toContainText('View Full Graph');
});
test('should emit event on View Full Graph click', async ({ page }) => {
// Set up navigation listener
const navigationPromise = page.waitForURL(/\/reachgraph\//);
await page.click('.view-graph-btn');
// Should navigate to full graph view
await navigationPromise;
});
test('should display analysis method info', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.85,
analysisMethod: 'static',
analysisTimestamp: '2026-01-10T12:00:00Z',
paths: [],
entryPoints: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const analysisInfo = page.locator('.analysis-info');
await expect(analysisInfo).toBeVisible();
await expect(analysisInfo).toContainText('Static Analysis');
});
test('should display entry points when available', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.75,
analysisMethod: 'hybrid',
entryPoints: ['main()', 'handleRequest()', 'processInput()'],
paths: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const entryPoints = page.locator('.entry-points');
await expect(entryPoints).toBeVisible();
const entryTags = page.locator('.entry-tag');
await expect(entryTags).toHaveCount(3);
});
test('should truncate entry points when more than 5', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.75,
entryPoints: ['a()', 'b()', 'c()', 'd()', 'e()', 'f()', 'g()'],
paths: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const entryTags = page.locator('.entry-tag');
await expect(entryTags).toHaveCount(5);
const moreIndicator = page.locator('.entry-more');
await expect(moreIndicator).toBeVisible();
await expect(moreIndicator).toContainText('+2 more');
});
test('should display path count when paths exist', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.90,
paths: [
{ nodes: ['a', 'b', 'vulnerable'] },
{ nodes: ['x', 'y', 'vulnerable'] },
],
entryPoints: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const pathCount = page.locator('.path-count');
await expect(pathCount).toBeVisible();
await expect(pathCount).toContainText('2 path(s) found');
});
test('should show empty state when no data', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify(null),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const emptyState = page.locator('.empty-state');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText('No reachability data available');
});
test('should display correct badge color for reachable status', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'reachable',
confidence: 0.95,
paths: [],
entryPoints: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const badge = page.locator('.status-badge');
await expect(badge).toHaveClass(/status-badge--reachable/);
await expect(badge).toContainText('Reachable');
});
test('should display correct badge color for unreachable status', async ({ page }) => {
await page.route('**/api/evidence/reachability/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
status: 'unreachable',
confidence: 0.98,
paths: [],
entryPoints: [],
}),
});
});
await page.reload();
await page.click('[aria-controls="panel-reachability"]');
const badge = page.locator('.status-badge');
await expect(badge).toHaveClass(/status-badge--unreachable/);
await expect(badge).toContainText('Unreachable');
});
});
test.describe('Copy JSON functionality', () => {
test('should have copy JSON button in provenance tab', async ({ page }) => {
const copyBtn = page.locator('.copy-json-btn');

View File

@@ -0,0 +1,276 @@
/**
* @file playbook-suggestions.e2e.spec.ts
* @sprint SPRINT_20260107_006_005_FE (OM-FE-006)
* @description E2E tests for OpsMemory playbook suggestions in decision drawer.
*/
import { test, expect } from '@playwright/test';
test.describe('Playbook Suggestions in Decision Drawer', () => {
test.beforeEach(async ({ page }) => {
// Mock the OpsMemory API
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: [
{
suggestedAction: 'accept_risk',
confidence: 0.85,
rationale: 'Similar situations resolved successfully with risk acceptance',
evidenceCount: 5,
matchingFactors: ['severity', 'reachability', 'componentType'],
evidence: [
{
memoryId: 'mem-abc123',
cveId: 'CVE-2023-44487',
action: 'accept_risk',
outcome: 'success',
resolutionTime: 'PT4H',
similarity: 0.92,
},
{
memoryId: 'mem-def456',
cveId: 'CVE-2023-12345',
action: 'accept_risk',
outcome: 'success',
resolutionTime: 'PT2H',
similarity: 0.87,
},
],
},
{
suggestedAction: 'target_fix',
confidence: 0.65,
rationale: 'Some similar situations required fixes',
evidenceCount: 2,
matchingFactors: ['severity'],
evidence: [
{
memoryId: 'mem-ghi789',
cveId: 'CVE-2023-99999',
action: 'target_fix',
outcome: 'success',
resolutionTime: 'P1DT4H',
similarity: 0.70,
},
],
},
],
situationHash: 'abc123def456',
}),
});
});
// Navigate to triage page (mock or real)
await page.goto('/triage/findings/test-finding-123');
});
test('playbook panel appears in decision drawer', async ({ page }) => {
// Open the decision drawer
await page.click('[data-testid="open-decision-drawer"]');
// Check playbook panel is visible
const playbookPanel = page.locator('stellaops-playbook-suggestion');
await expect(playbookPanel).toBeVisible();
// Check header text
await expect(playbookPanel.locator('.playbook-panel__title')).toContainText(
'Past Decisions'
);
});
test('shows suggestions with confidence badges', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
// Wait for suggestions to load
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Check first suggestion
const firstSuggestion = page.locator('.playbook-suggestion').first();
await expect(firstSuggestion).toBeVisible();
// Check action badge
await expect(
firstSuggestion.locator('.playbook-suggestion__action')
).toContainText('Accept Risk');
// Check confidence
await expect(
firstSuggestion.locator('.playbook-suggestion__confidence')
).toContainText('85%');
});
test('clicking "Use This Approach" pre-fills decision form', async ({
page,
}) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Click "Use This Approach" on first suggestion
await page.click('.playbook-btn--use');
// Verify form was pre-filled
const statusRadio = page.locator('input[name="status"][value="not_affected"]');
await expect(statusRadio).toBeChecked();
// Check reason notes contain suggestion context
const reasonText = page.locator('.reason-text');
await expect(reasonText).toContainText('similar past decisions');
});
test('expanding evidence details shows past decisions', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Click "Show Details" on first suggestion
await page.click('.playbook-btn--expand');
// Check evidence cards are visible
const evidenceCards = page.locator('stellaops-evidence-card');
await expect(evidenceCards).toHaveCount(2);
// Check evidence content
const firstCard = evidenceCards.first();
await expect(firstCard.locator('.evidence-card__cve')).toContainText(
'CVE-2023-44487'
);
await expect(firstCard.locator('.evidence-card__similarity')).toContainText(
'92%'
);
});
test('shows empty state when no suggestions', async ({ page }) => {
// Override route to return empty
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: [],
situationHash: 'empty123',
}),
});
});
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Check empty state message
await expect(page.locator('.playbook-empty')).toContainText(
'No similar past decisions'
);
});
test('handles API errors gracefully', async ({ page }) => {
// Override route to return error
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Internal server error' }),
});
});
await page.click('[data-testid="open-decision-drawer"]');
// Wait for error state
await expect(page.locator('.playbook-error')).toBeVisible();
// Check retry button
const retryBtn = page.locator('.playbook-error__retry');
await expect(retryBtn).toBeVisible();
});
test('retry button refetches suggestions', async ({ page }) => {
let callCount = 0;
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
callCount++;
if (callCount === 1) {
await route.fulfill({ status: 500 });
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: [
{
suggestedAction: 'accept_risk',
confidence: 0.85,
rationale: 'Test',
evidenceCount: 1,
matchingFactors: [],
evidence: [],
},
],
situationHash: 'retry123',
}),
});
}
});
await page.click('[data-testid="open-decision-drawer"]');
// Wait for error state
await expect(page.locator('.playbook-error')).toBeVisible();
// Click retry
await page.click('.playbook-error__retry');
// Should now show suggestions
await expect(page.locator('.playbook-suggestion')).toBeVisible();
});
test('keyboard navigation works', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Focus on playbook panel header
await page.focus('.playbook-panel__header');
// Press Enter to toggle (if collapsed)
await page.keyboard.press('Enter');
// Tab to first suggestion's "Use This Approach" button
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
// Press Enter to use suggestion
await page.keyboard.press('Enter');
// Verify form was pre-filled
const statusRadio = page.locator('input[name="status"][value="not_affected"]');
await expect(statusRadio).toBeChecked();
});
test('panel can be collapsed and expanded', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
// Panel content should be visible
await expect(page.locator('.playbook-panel__content')).toBeVisible();
// Click header to collapse
await page.click('.playbook-panel__header');
// Content should be hidden
await expect(page.locator('.playbook-panel__content')).not.toBeVisible();
// Click again to expand
await page.click('.playbook-panel__header');
// Content should be visible again
await expect(page.locator('.playbook-panel__content')).toBeVisible();
});
test('matching factors are displayed', async ({ page }) => {
await page.click('[data-testid="open-decision-drawer"]');
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
const factors = page.locator('.playbook-suggestion__factor');
await expect(factors).toHaveCount(3);
await expect(factors.first()).toContainText('severity');
});
});

View File

@@ -0,0 +1,610 @@
// -----------------------------------------------------------------------------
// 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();
});
});
});