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:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View 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();
});
});

View 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 });
}
});
});