sprints work
This commit is contained in:
@@ -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');
|
||||
|
||||
276
src/Web/StellaOps.Web/e2e/playbook-suggestions.e2e.spec.ts
Normal file
276
src/Web/StellaOps.Web/e2e/playbook-suggestions.e2e.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
610
src/Web/StellaOps.Web/e2e/sbom-evidence.e2e.spec.ts
Normal file
610
src/Web/StellaOps.Web/e2e/sbom-evidence.e2e.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user