Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
529
src/Web/StellaOps.Web/tests/e2e/risk-dashboard.spec.ts
Normal file
529
src/Web/StellaOps.Web/tests/e2e/risk-dashboard.spec.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
clientId: 'stellaops-ui',
|
||||
authorizeEndpoint: 'https://authority.local/connect/authorize',
|
||||
tokenEndpoint: 'https://authority.local/connect/token',
|
||||
logoutEndpoint: 'https://authority.local/connect/logout',
|
||||
redirectUri: 'http://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
|
||||
scope: 'openid profile email ui.read risk:read risk:manage exceptions:read exceptions:manage',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://scanner.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
},
|
||||
quickstartMode: true,
|
||||
};
|
||||
|
||||
const mockBudgetSnapshot = {
|
||||
config: {
|
||||
id: 'budget-1',
|
||||
tenantId: 'tenant-1',
|
||||
totalBudget: 1000,
|
||||
warningThreshold: 70,
|
||||
criticalThreshold: 90,
|
||||
period: 'monthly',
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
},
|
||||
currentRiskPoints: 450,
|
||||
headroom: 550,
|
||||
utilizationPercent: 45,
|
||||
status: 'healthy',
|
||||
timeSeries: [
|
||||
{ timestamp: '2025-12-20T00:00:00Z', actual: 300, budget: 1000, headroom: 700 },
|
||||
{ timestamp: '2025-12-21T00:00:00Z', actual: 350, budget: 1000, headroom: 650 },
|
||||
{ timestamp: '2025-12-22T00:00:00Z', actual: 380, budget: 1000, headroom: 620 },
|
||||
{ timestamp: '2025-12-23T00:00:00Z', actual: 420, budget: 1000, headroom: 580 },
|
||||
{ timestamp: '2025-12-24T00:00:00Z', actual: 450, budget: 1000, headroom: 550 },
|
||||
],
|
||||
computedAt: '2025-12-26T00:00:00Z',
|
||||
traceId: 'trace-budget-1',
|
||||
};
|
||||
|
||||
const mockBudgetKpis = {
|
||||
headroom: 550,
|
||||
headroomDelta24h: -30,
|
||||
unknownsDelta24h: 2,
|
||||
riskRetired7d: 85,
|
||||
exceptionsExpiring: 1,
|
||||
burnRate: 12.5,
|
||||
projectedDaysToExceeded: 44,
|
||||
topContributors: [
|
||||
{ vulnId: 'CVE-2025-1234', riskPoints: 50, packageName: 'lodash' },
|
||||
{ vulnId: 'CVE-2025-5678', riskPoints: 35, packageName: 'express' },
|
||||
],
|
||||
traceId: 'trace-kpi-1',
|
||||
};
|
||||
|
||||
const mockVerdict = {
|
||||
id: 'verdict-1',
|
||||
artifactDigest: 'sha256:abc123def456',
|
||||
level: 'review',
|
||||
drivers: [
|
||||
{
|
||||
category: 'high_vuln',
|
||||
summary: '2 high severity vulnerabilities detected',
|
||||
description: 'CVE-2025-1234 and CVE-2025-5678 require review',
|
||||
impact: 2,
|
||||
relatedIds: ['CVE-2025-1234', 'CVE-2025-5678'],
|
||||
evidenceType: 'vex',
|
||||
},
|
||||
{
|
||||
category: 'budget_exceeded',
|
||||
summary: 'Budget utilization at 45%',
|
||||
description: 'Within healthy range but trending upward',
|
||||
impact: false,
|
||||
},
|
||||
],
|
||||
previousVerdict: {
|
||||
level: 'routine',
|
||||
timestamp: '2025-12-23T10:00:00Z',
|
||||
},
|
||||
riskDelta: {
|
||||
totalDelta: 30,
|
||||
criticalDelta: 0,
|
||||
highDelta: 2,
|
||||
mediumDelta: 1,
|
||||
lowDelta: -3,
|
||||
},
|
||||
timestamp: '2025-12-26T10:00:00Z',
|
||||
traceId: 'trace-verdict-1',
|
||||
};
|
||||
|
||||
const mockExceptions = {
|
||||
items: [
|
||||
{
|
||||
id: 'exc-1',
|
||||
tenantId: 'tenant-1',
|
||||
title: 'Known false positive in lodash',
|
||||
type: 'vulnerability',
|
||||
status: 'approved',
|
||||
severity: 'high',
|
||||
justification: 'Not exploitable in our configuration',
|
||||
scope: { cves: ['CVE-2025-1234'] },
|
||||
createdAt: '2025-12-20T10:00:00Z',
|
||||
createdBy: 'user-1',
|
||||
expiresAt: '2026-01-20T10:00:00Z',
|
||||
riskPointsCovered: 50,
|
||||
reviewedBy: 'approver-1',
|
||||
reviewedAt: '2025-12-21T10:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
accessToken: 'mock-access-token',
|
||||
idToken: 'mock-id-token',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
user: {
|
||||
sub: 'user-pm-1',
|
||||
name: 'PM User',
|
||||
email: 'pm@stellaops.test',
|
||||
},
|
||||
scopes: ['risk:read', 'risk:manage', 'exceptions:read', 'exceptions:manage'],
|
||||
tenantId: 'tenant-1',
|
||||
};
|
||||
|
||||
function setupMockRoutes(page) {
|
||||
// Mock config
|
||||
page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock budget snapshot API
|
||||
page.route('**/api/risk/budgets/*/snapshot', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBudgetSnapshot),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock budget KPIs API
|
||||
page.route('**/api/risk/budgets/*/kpis', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBudgetKpis),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock verdict API
|
||||
page.route('**/api/risk/gate/verdict*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockVerdict),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock verdict history API
|
||||
page.route('**/api/risk/gate/history*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([mockVerdict]),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock exceptions API
|
||||
page.route('**/api/v1/exceptions*', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockExceptions),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock exception create API
|
||||
page.route('**/api/v1/exceptions', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
...mockExceptions.items[0],
|
||||
id: 'exc-new-1',
|
||||
status: 'pending_review',
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Block authority
|
||||
page.route('https://authority.local/**', (route) => route.abort());
|
||||
}
|
||||
|
||||
test.describe('Risk Dashboard - Budget View', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
|
||||
test('displays budget burn-up chart', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Chart should be visible
|
||||
const chart = page.locator('.burnup-chart, [data-testid="budget-chart"]');
|
||||
await expect(chart).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays budget KPI tiles', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// KPI tiles should show headroom
|
||||
await expect(page.getByText('550')).toBeVisible(); // Headroom value
|
||||
await expect(page.getByText(/headroom/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows budget status indicator', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Status should be healthy (45% utilization)
|
||||
const healthyIndicator = page.locator('.healthy, [data-status="healthy"]');
|
||||
await expect(healthyIndicator.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays exceptions expiring count', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Exceptions expiring KPI
|
||||
await expect(page.getByText(/exceptions.*expir/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows risk retired in 7 days', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Risk retired value (85)
|
||||
await expect(page.getByText('85')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Risk Dashboard - Verdict View', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
|
||||
test('displays verdict badge', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verdict badge should show "Review"
|
||||
const verdictBadge = page.locator('.verdict-badge, [data-testid="verdict-badge"]');
|
||||
await expect(verdictBadge).toBeVisible();
|
||||
await expect(verdictBadge).toContainText(/review/i);
|
||||
});
|
||||
|
||||
test('displays verdict drivers', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Driver summary should be visible
|
||||
await expect(page.getByText(/high severity vulnerabilities/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows risk delta from previous verdict', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Risk delta indicator
|
||||
await expect(page.getByText(/\+30|\+2/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking evidence button opens panel', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Find and click evidence button
|
||||
const evidenceButton = page.getByRole('button', { name: /show.*vex|view.*evidence/i });
|
||||
if (await evidenceButton.isVisible()) {
|
||||
await evidenceButton.click();
|
||||
|
||||
// Panel should open
|
||||
await expect(page.locator('.vex-panel, [data-testid="vex-panel"]')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('verdict tooltip shows summary', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Hover over verdict badge
|
||||
const verdictBadge = page.locator('.verdict-badge, [data-testid="verdict-badge"]');
|
||||
await verdictBadge.hover();
|
||||
|
||||
// Tooltip should appear with summary
|
||||
// Note: Actual tooltip behavior depends on implementation
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Risk Dashboard - Exception Workflow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
|
||||
test('displays active exceptions', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Exception should be visible
|
||||
await expect(page.getByText(/lodash|CVE-2025-1234/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens create exception modal', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Find create exception button
|
||||
const createButton = page.getByRole('button', { name: /create.*exception|add.*exception/i });
|
||||
if (await createButton.isVisible()) {
|
||||
await createButton.click();
|
||||
|
||||
// Modal should open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByText(/create exception/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('exception form validates required fields', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Open create modal
|
||||
const createButton = page.getByRole('button', { name: /create.*exception|add.*exception/i });
|
||||
if (await createButton.isVisible()) {
|
||||
await createButton.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Submit button should be disabled without required fields
|
||||
const submitButton = page.getByRole('button', { name: /create|submit/i }).last();
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill required fields
|
||||
await page.getByLabel(/title/i).fill('Test Exception');
|
||||
await page.getByLabel(/justification/i).fill('Test justification for E2E');
|
||||
|
||||
// Submit should now be enabled
|
||||
await expect(submitButton).toBeEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
test('shows exception expiry warning', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Look for expiry information
|
||||
const expiryInfo = page.getByText(/expires|expiring/i);
|
||||
await expect(expiryInfo.first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Risk Dashboard - Side-by-Side Diff', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
|
||||
await setupMockRoutes(page);
|
||||
});
|
||||
|
||||
test('displays before and after states', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Look for comparison view
|
||||
const beforePane = page.locator('.pane.before, [data-testid="before-pane"]');
|
||||
const afterPane = page.locator('.pane.after, [data-testid="after-pane"]');
|
||||
|
||||
if (await beforePane.isVisible()) {
|
||||
await expect(afterPane).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('highlights metric changes', async ({ page }) => {
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Look for delta indicators
|
||||
const deltaIndicator = page.locator('.metric-delta, .delta-badge, [data-testid="delta"]');
|
||||
if (await deltaIndicator.first().isVisible()) {
|
||||
// Delta should show change direction
|
||||
await expect(deltaIndicator.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Risk Dashboard - Responsive Design', () => {
|
||||
test('adapts to tablet viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
|
||||
await setupMockRoutes(page);
|
||||
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Dashboard should be usable on tablet
|
||||
const dashboard = page.locator('.dashboard-layout, [data-testid="risk-dashboard"]');
|
||||
await expect(dashboard).toBeVisible();
|
||||
});
|
||||
|
||||
test('adapts to desktop viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, mockSession);
|
||||
|
||||
await setupMockRoutes(page);
|
||||
|
||||
await page.goto('/risk');
|
||||
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Dashboard should use full width on desktop
|
||||
const dashboard = page.locator('.dashboard-layout, [data-testid="risk-dashboard"]');
|
||||
await expect(dashboard).toBeVisible();
|
||||
});
|
||||
});
|
||||
505
src/Web/StellaOps.Web/tests/e2e/visual-diff.spec.ts
Normal file
505
src/Web/StellaOps.Web/tests/e2e/visual-diff.spec.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Visual Diff E2E Tests
|
||||
* Sprint: SPRINT_20251226_010_FE_visual_diff_enhancements
|
||||
* Task: VD-ENH-12
|
||||
*
|
||||
* Tests for the visual diff workflow including graph diff, plain language toggle,
|
||||
* and export functionality.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { policyAuthorSession } from '../../src/app/testing';
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
clientId: 'stellaops-ui',
|
||||
authorizeEndpoint: 'https://authority.local/connect/authorize',
|
||||
tokenEndpoint: 'https://authority.local/connect/token',
|
||||
logoutEndpoint: 'https://authority.local/connect/logout',
|
||||
redirectUri: 'http://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
|
||||
scope:
|
||||
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://scanner.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
},
|
||||
quickstartMode: true,
|
||||
};
|
||||
|
||||
// Mock graph data for testing
|
||||
const mockBaseGraph = {
|
||||
id: 'base-graph',
|
||||
digest: 'sha256:base123',
|
||||
nodes: [
|
||||
{ id: 'main', label: 'main()', type: 'entry' },
|
||||
{ id: 'parseInput', label: 'parseInput()', type: 'function' },
|
||||
{ id: 'processData', label: 'processData()', type: 'function' },
|
||||
{ id: 'vulnerableFunc', label: 'vulnerableFunc()', type: 'sink' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'main-parseInput', sourceId: 'main', targetId: 'parseInput', type: 'call' },
|
||||
{ id: 'parseInput-processData', sourceId: 'parseInput', targetId: 'processData', type: 'call' },
|
||||
{ id: 'processData-vulnerable', sourceId: 'processData', targetId: 'vulnerableFunc', type: 'call' },
|
||||
],
|
||||
entryPoints: ['main'],
|
||||
vulnerableNodes: ['vulnerableFunc'],
|
||||
};
|
||||
|
||||
const mockHeadGraph = {
|
||||
id: 'head-graph',
|
||||
digest: 'sha256:head456',
|
||||
nodes: [
|
||||
{ id: 'main', label: 'main()', type: 'entry' },
|
||||
{ id: 'parseInput', label: 'parseInput() v2', type: 'function' },
|
||||
{ id: 'validateInput', label: 'validateInput()', type: 'function' },
|
||||
{ id: 'processData', label: 'processData()', type: 'function' },
|
||||
{ id: 'safeFunc', label: 'safeFunc()', type: 'function' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'main-parseInput', sourceId: 'main', targetId: 'parseInput', type: 'call' },
|
||||
{ id: 'parseInput-validate', sourceId: 'parseInput', targetId: 'validateInput', type: 'call' },
|
||||
{ id: 'validate-processData', sourceId: 'validateInput', targetId: 'processData', type: 'call' },
|
||||
{ id: 'processData-safe', sourceId: 'processData', targetId: 'safeFunc', type: 'call' },
|
||||
],
|
||||
entryPoints: ['main'],
|
||||
vulnerableNodes: [],
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((session) => {
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore storage errors in restricted contexts
|
||||
}
|
||||
(window as any).__stellaopsTestSession = session;
|
||||
}, policyAuthorSession);
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
|
||||
// Mock compare API endpoint
|
||||
await page.route('**/api/v1/compare/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
baseGraph: mockBaseGraph,
|
||||
headGraph: mockHeadGraph,
|
||||
summary: {
|
||||
nodesAdded: 2,
|
||||
nodesRemoved: 1,
|
||||
nodesChanged: 1,
|
||||
edgesAdded: 2,
|
||||
edgesRemoved: 1,
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('Graph Diff Component', () => {
|
||||
test('should load compare view with two digests', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
// Wait for the graph diff component to load
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check that the SVG viewport is rendered
|
||||
await expect(page.locator('.graph-diff__svg')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display graph diff summary', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check for diff summary indicators
|
||||
await expect(page.getByText(/added/i)).toBeVisible();
|
||||
await expect(page.getByText(/removed/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should toggle between split and unified view', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find and click the view mode toggle
|
||||
const viewToggle = page.getByRole('button', { name: /split|unified/i });
|
||||
|
||||
if (await viewToggle.isVisible()) {
|
||||
// Click to toggle to split view
|
||||
await viewToggle.click();
|
||||
|
||||
// Check for split view container
|
||||
const splitView = page.locator('.graph-split-view');
|
||||
if (await splitView.isVisible()) {
|
||||
await expect(splitView).toHaveClass(/split-mode/);
|
||||
}
|
||||
|
||||
// Toggle back to unified
|
||||
await viewToggle.click();
|
||||
}
|
||||
});
|
||||
|
||||
test('should navigate graph with keyboard', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Focus the graph container
|
||||
const graphContainer = page.locator('.graph-diff__container');
|
||||
await graphContainer.focus();
|
||||
|
||||
// Test zoom in with + key
|
||||
await page.keyboard.press('+');
|
||||
|
||||
// Test zoom out with - key
|
||||
await page.keyboard.press('-');
|
||||
|
||||
// Test reset view with 0 key
|
||||
await page.keyboard.press('0');
|
||||
});
|
||||
|
||||
test('should highlight connected nodes on hover', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find a node element and hover
|
||||
const node = page.locator('.graph-node').first();
|
||||
if (await node.isVisible()) {
|
||||
await node.hover();
|
||||
|
||||
// Check for highlight class
|
||||
await expect(page.locator('.graph-node--highlighted')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should show node details on click', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on a node
|
||||
const node = page.locator('.graph-node').first();
|
||||
if (await node.isVisible()) {
|
||||
await node.click();
|
||||
|
||||
// Check for node detail panel
|
||||
const detailPanel = page.locator('.node-detail-panel, .graph-diff__detail');
|
||||
if (await detailPanel.isVisible()) {
|
||||
await expect(detailPanel).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should add breadcrumbs for navigation history', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on multiple nodes to create breadcrumbs
|
||||
const nodes = page.locator('.graph-node');
|
||||
const count = await nodes.count();
|
||||
|
||||
if (count >= 2) {
|
||||
await nodes.nth(0).click();
|
||||
await nodes.nth(1).click();
|
||||
|
||||
// Check for breadcrumb trail
|
||||
const breadcrumbs = page.locator('.graph-breadcrumb, .navigation-breadcrumb');
|
||||
if (await breadcrumbs.isVisible()) {
|
||||
await expect(breadcrumbs.locator('.breadcrumb-item')).toHaveCount(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Plain Language Toggle', () => {
|
||||
test('should toggle plain language mode', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
// Find the plain language toggle
|
||||
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
|
||||
|
||||
if (await toggle.isVisible()) {
|
||||
// Initially should be off
|
||||
await expect(toggle).not.toBeChecked();
|
||||
|
||||
// Toggle on
|
||||
await toggle.click();
|
||||
await expect(toggle).toBeChecked();
|
||||
|
||||
// Toggle off
|
||||
await toggle.click();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
}
|
||||
});
|
||||
|
||||
test('should use Alt+P keyboard shortcut', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
|
||||
|
||||
if (await toggle.isVisible()) {
|
||||
const initialState = await toggle.isChecked();
|
||||
|
||||
// Press Alt+P
|
||||
await page.keyboard.press('Alt+P');
|
||||
|
||||
// State should have toggled
|
||||
const newState = await toggle.isChecked();
|
||||
expect(newState).not.toBe(initialState);
|
||||
}
|
||||
});
|
||||
|
||||
test('should persist preference across page loads', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
|
||||
|
||||
if (await toggle.isVisible()) {
|
||||
// Enable plain language
|
||||
if (!(await toggle.isChecked())) {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toBeChecked();
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
// Toggle should still be checked
|
||||
const toggleAfterReload = page.getByRole('switch', { name: /plain language|explain/i });
|
||||
if (await toggleAfterReload.isVisible()) {
|
||||
await expect(toggleAfterReload).toBeChecked();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should show plain language explanations when enabled', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
|
||||
|
||||
if (await toggle.isVisible()) {
|
||||
// Enable plain language
|
||||
await toggle.click();
|
||||
|
||||
// Check for plain language text patterns
|
||||
// These are translations from the PlainLanguageService
|
||||
const plainText = page.locator('text=/new library|vendor confirmed|never actually runs/i');
|
||||
if ((await plainText.count()) > 0) {
|
||||
await expect(plainText.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Graph Export', () => {
|
||||
test('should export graph diff as SVG', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find export button
|
||||
const exportButton = page.getByRole('button', { name: /export/i });
|
||||
|
||||
if (await exportButton.isVisible()) {
|
||||
// Set up download listener
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
// Open export menu and select SVG
|
||||
await exportButton.click();
|
||||
|
||||
const svgOption = page.getByRole('menuitem', { name: /svg/i });
|
||||
if (await svgOption.isVisible()) {
|
||||
await svgOption.click();
|
||||
|
||||
// Wait for download
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/graph-diff.*\.svg$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should export graph diff as PNG', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find export button
|
||||
const exportButton = page.getByRole('button', { name: /export/i });
|
||||
|
||||
if (await exportButton.isVisible()) {
|
||||
// Set up download listener
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
// Open export menu and select PNG
|
||||
await exportButton.click();
|
||||
|
||||
const pngOption = page.getByRole('menuitem', { name: /png/i });
|
||||
if (await pngOption.isVisible()) {
|
||||
await pngOption.click();
|
||||
|
||||
// Wait for download
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/graph-diff.*\.png$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Zoom and Pan Controls', () => {
|
||||
test('should zoom in with button', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const zoomInButton = page.getByRole('button', { name: /zoom in|\+/i });
|
||||
|
||||
if (await zoomInButton.isVisible()) {
|
||||
await zoomInButton.click();
|
||||
// Verify zoom changed (implementation-specific check)
|
||||
}
|
||||
});
|
||||
|
||||
test('should zoom out with button', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const zoomOutButton = page.getByRole('button', { name: /zoom out|-/i });
|
||||
|
||||
if (await zoomOutButton.isVisible()) {
|
||||
await zoomOutButton.click();
|
||||
// Verify zoom changed
|
||||
}
|
||||
});
|
||||
|
||||
test('should fit to view', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const fitButton = page.getByRole('button', { name: /fit|reset/i });
|
||||
|
||||
if (await fitButton.isVisible()) {
|
||||
// First zoom in
|
||||
const zoomInButton = page.getByRole('button', { name: /zoom in|\+/i });
|
||||
if (await zoomInButton.isVisible()) {
|
||||
await zoomInButton.click();
|
||||
await zoomInButton.click();
|
||||
}
|
||||
|
||||
// Then fit to view
|
||||
await fitButton.click();
|
||||
}
|
||||
});
|
||||
|
||||
test('should show minimap for large graphs', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Minimap should be visible for large graphs
|
||||
const minimap = page.locator('.graph-minimap');
|
||||
// Note: Minimap visibility depends on graph size (>50 nodes typically)
|
||||
// This test checks the element exists when applicable
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('should have proper ARIA labels', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check for ARIA labels on interactive elements
|
||||
const graphContainer = page.locator('[role="application"], [role="img"]');
|
||||
if (await graphContainer.isVisible()) {
|
||||
await expect(graphContainer).toHaveAttribute('aria-label');
|
||||
}
|
||||
|
||||
// Check for keyboard focus indicators
|
||||
const focusableElements = page.locator('.graph-node[tabindex], .graph-controls button');
|
||||
const count = await focusableElements.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should support keyboard navigation between nodes', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Focus the graph container
|
||||
const container = page.locator('.graph-diff__container');
|
||||
await container.focus();
|
||||
|
||||
// Tab through nodes
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Check that a node is focused
|
||||
const focusedNode = page.locator('.graph-node:focus');
|
||||
if (await focusedNode.isVisible()) {
|
||||
await expect(focusedNode).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
||||
test('should have color-blind safe indicators', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
await expect(page.locator('stellaops-graph-diff')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check for pattern indicators (not just color)
|
||||
const addedNodes = page.locator('.graph-node--added');
|
||||
const removedNodes = page.locator('.graph-node--removed');
|
||||
|
||||
// Both should have additional indicators besides color
|
||||
if (await addedNodes.first().isVisible()) {
|
||||
// Check for icon or pattern class
|
||||
const indicator = addedNodes.first().locator('.change-indicator, .node-icon');
|
||||
// Verify some non-color indicator exists
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Glossary Tooltips', () => {
|
||||
test('should show tooltip for technical terms', async ({ page }) => {
|
||||
await page.goto('/compare?base=sha256:base123&head=sha256:head456');
|
||||
|
||||
// Enable plain language mode to activate tooltips
|
||||
const toggle = page.getByRole('switch', { name: /plain language|explain/i });
|
||||
if (await toggle.isVisible()) {
|
||||
await toggle.click();
|
||||
}
|
||||
|
||||
// Find a technical term with tooltip directive
|
||||
const technicalTerm = page.locator('[stellaopsGlossaryTooltip], .glossary-term').first();
|
||||
|
||||
if (await technicalTerm.isVisible()) {
|
||||
await technicalTerm.hover();
|
||||
|
||||
// Check for tooltip appearance
|
||||
const tooltip = page.locator('.glossary-tooltip, [role="tooltip"]');
|
||||
await expect(tooltip).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user