consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -297,7 +297,7 @@ test.describe('Pack route render checks', () => {
'/ops',
'/ops/operations',
'/ops/operations/data-integrity',
'/ops/operations/orchestrator',
'/ops/operations/jobengine',
'/ops/integrations',
'/ops/integrations/advisory-vex-sources',
'/ops/policy',

View File

@@ -108,7 +108,7 @@ const EXPECTATIONS: PackExpectation[] = [
{ pack: '22', path: '/ops', text: /Ops|Overview/i, canonical: /\/ops$/ },
{ pack: '22', path: '/ops/operations', text: /Operations|Platform Ops/i, canonical: /\/ops\/operations$/ },
{ pack: '22', path: '/ops/operations/data-integrity', text: /Data Integrity|Trust/i, canonical: /\/ops\/operations\/data-integrity/ },
{ pack: '22', path: '/ops/operations/orchestrator', text: /Orchestrator/i, canonical: /\/ops\/operations\/orchestrator$/ },
{ pack: '22', path: '/ops/operations/jobengine', text: /JobEngine/i, canonical: /\/ops\/operations\/jobengine$/ },
{ pack: '22', path: '/ops/integrations', text: /Integration Hub|Integrations/i, canonical: /\/ops\/integrations$/ },
{ pack: '22', path: '/ops/integrations/advisory-vex-sources', text: /Advisory|VEX|Source|FeedMirror|Integrations/i, canonical: /\/ops\/integrations\/advisory-vex-sources$/ },
{ pack: '22', path: '/ops/policy', text: /Policy|Governance/i, canonical: /\/ops\/policy/ },

View File

@@ -111,7 +111,7 @@ const canonicalRoutes = [
'/ops/operations/data-integrity',
'/ops/operations/system-health',
'/ops/operations/health-slo',
'/ops/operations/orchestrator',
'/ops/operations/jobengine',
'/ops/operations/scheduler',
'/ops/operations/quotas',
'/ops/operations/offline-kit',
@@ -493,7 +493,7 @@ async function setupHarness(page: Page): Promise<void> {
byModule: {
authority: 0,
policy: 1,
orchestrator: 0,
jobengine: 0,
integrations: 0,
vex: 0,
scanner: 0,

View File

@@ -1,5 +1,24 @@
import { expect, test } from '@playwright/test';
const stubSession = {
subjectId: 'risk-e2e-user',
tenant: 'tenant-1',
scopes: [
'ui.read',
'scanner:read',
'sbom:read',
'advisory:read',
'vex:read',
'exception:read',
'exceptions:read',
'exceptions:write',
'exceptions:approve',
'findings:read',
'vuln:view',
'risk:read',
],
};
const mockConfig = {
authority: {
issuer: 'https://authority.local',
@@ -9,7 +28,7 @@ const mockConfig = {
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',
scope: 'openid profile email ui.read scanner:read sbom:read advisory:read vex:read exception:read exceptions:read exceptions:write exceptions:approve findings:read vuln:view risk:read',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
@@ -17,13 +36,64 @@ const mockConfig = {
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
policy: 'https://policy.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
gateway: 'https://gateway.local',
},
quickstartMode: true,
};
const mockRiskList = {
items: [
{
id: 'risk-1',
title: 'Critical RCE path',
description: 'Reachable from public ingress',
severity: 'critical',
score: 96,
tenantId: 'tenant-1',
lastEvaluatedAt: '2026-02-26T10:00:00Z',
},
{
id: 'risk-2',
title: 'Insecure dependency',
description: 'Pending runtime confirmation',
severity: 'high',
score: 78,
tenantId: 'tenant-1',
lastEvaluatedAt: '2026-02-26T10:00:00Z',
},
],
total: 2,
page: 1,
pageSize: 20,
traceId: 'trace-risk-list',
};
const mockRiskStats = {
countsBySeverity: {
critical: 1,
high: 1,
medium: 0,
low: 0,
info: 0,
none: 0,
},
countsByCategory: {
vulnerability: 2,
misconfiguration: 0,
compliance: 0,
supply_chain: 0,
secret: 0,
other: 0,
},
lastComputation: '2026-02-26T10:00:00Z',
totalScore: 174,
averageScore: 87,
traceId: 'trace-risk-stats',
};
const mockBudgetSnapshot = {
config: {
id: 'budget-1',
@@ -32,35 +102,33 @@ const mockBudgetSnapshot = {
warningThreshold: 70,
criticalThreshold: 90,
period: 'monthly',
createdAt: '2025-12-01T00:00:00Z',
updatedAt: '2025-12-01T00:00:00Z',
createdAt: '2026-02-01T00:00:00Z',
updatedAt: '2026-02-26T00: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 },
{ timestamp: '2026-02-24T00:00:00Z', actual: 420, budget: 1000, headroom: 580 },
{ timestamp: '2026-02-25T00:00:00Z', actual: 440, budget: 1000, headroom: 560 },
{ timestamp: '2026-02-26T00:00:00Z', actual: 450, budget: 1000, headroom: 550 },
],
computedAt: '2025-12-26T00:00:00Z',
computedAt: '2026-02-26T00:00:00Z',
traceId: 'trace-budget-1',
};
const mockBudgetKpis = {
headroom: 550,
headroomDelta24h: -30,
unknownsDelta24h: 2,
riskRetired7d: 85,
headroomDelta24h: -10,
unknownsDelta24h: 1,
riskRetired7d: 25,
exceptionsExpiring: 1,
burnRate: 12.5,
projectedDaysToExceeded: 44,
burnRate: 8.5,
projectedDaysToExceeded: 64,
topContributors: [
{ vulnId: 'CVE-2025-1234', riskPoints: 50, packageName: 'lodash' },
{ vulnId: 'CVE-2025-5678', riskPoints: 35, packageName: 'express' },
{ vulnId: 'CVE-2026-1111', riskPoints: 70, packageName: 'openssl' },
{ vulnId: 'CVE-2026-2222', riskPoints: 42, packageName: 'nginx' },
],
traceId: 'trace-kpi-1',
};
@@ -73,71 +141,103 @@ const mockVerdict = {
{
category: 'high_vuln',
summary: '2 high severity vulnerabilities detected',
description: 'CVE-2025-1234 and CVE-2025-5678 require review',
description: 'CVE-2026-1111 and CVE-2026-2222 require review',
impact: 2,
relatedIds: ['CVE-2025-1234', 'CVE-2025-5678'],
relatedIds: ['CVE-2026-1111', 'CVE-2026-2222'],
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',
timestamp: '2026-02-25T10:00:00Z',
},
riskDelta: {
totalDelta: 30,
criticalDelta: 0,
highDelta: 2,
mediumDelta: 1,
lowDelta: -3,
added: 2,
removed: 0,
net: 2,
},
timestamp: '2025-12-26T10:00:00Z',
timestamp: '2026-02-26T10:00:00Z',
traceId: 'trace-verdict-1',
};
const mockExceptions = {
items: [
{
id: 'exc-1',
schemaVersion: '1.0',
exceptionId: 'exc-1',
tenantId: 'tenant-1',
title: 'Known false positive in lodash',
type: 'vulnerability',
name: 'known-false-positive',
displayName: 'Known false positive in openssl',
status: 'approved',
severity: 'high',
justification: 'Not exploitable in our configuration',
scope: { cves: ['CVE-2025-1234'] },
createdAt: '2025-12-20T10:00:00Z',
type: 'vulnerability',
scope: {
type: 'asset',
tenantId: 'tenant-1',
vulnIds: ['CVE-2026-1111'],
cves: ['CVE-2026-1111'],
componentPurls: ['pkg:oci/openssl@3.0.2'],
packages: ['pkg:oci/openssl@3.0.2'],
images: ['registry/app:latest'],
},
justification: {
text: 'Not exploitable in this deployment profile',
},
timebox: {
startDate: '2026-02-20T10:00:00Z',
endDate: '2026-03-10T10:00:00Z',
autoRenew: false,
},
approvals: [
{
approvalId: 'approval-1',
approvedBy: 'approver-1',
approvedAt: '2026-02-21T10:00:00Z',
},
],
auditTrail: [
{
auditId: 'audit-1',
action: 'created',
actor: 'user-1',
timestamp: '2026-02-20T10:00:00Z',
},
],
labels: { source: 'e2e' },
createdBy: 'user-1',
expiresAt: '2026-01-20T10:00:00Z',
riskPointsCovered: 50,
reviewedBy: 'approver-1',
reviewedAt: '2025-12-21T10:00:00Z',
createdAt: '2026-02-20T10:00:00Z',
updatedAt: '2026-02-21T10:00:00Z',
},
],
total: 1,
count: 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',
};
async function setupMockRoutes(page: import('@playwright/test').Page): Promise<void> {
await page.route('https://authority.local/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
issuer: 'https://authority.local',
authorization_endpoint: 'https://authority.local/connect/authorize',
token_endpoint: 'https://authority.local/connect/token',
}),
})
);
function setupMockRoutes(page) {
// Mock config
page.route('**/config.json', (route) =>
await page.route('**/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
issuer: 'https://authority.local',
authorization_endpoint: 'https://authority.local/connect/authorize',
token_endpoint: 'https://authority.local/connect/token',
}),
})
);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -145,8 +245,24 @@ function setupMockRoutes(page) {
})
);
// Mock budget snapshot API
page.route('**/api/risk/budgets/*/snapshot', (route) =>
await page.route('**/risk/risk**', (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith('/status')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRiskStats),
});
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRiskList),
});
});
await page.route('**/api/risk-budget/snapshot**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -154,8 +270,7 @@ function setupMockRoutes(page) {
})
);
// Mock budget KPIs API
page.route('**/api/risk/budgets/*/kpis', (route) =>
await page.route('**/api/risk-budget/kpis**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -163,8 +278,7 @@ function setupMockRoutes(page) {
})
);
// Mock verdict API
page.route('**/api/risk/gate/verdict*', (route) =>
await page.route('**/api/gate/verdict**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -172,358 +286,102 @@ function setupMockRoutes(page) {
})
);
// 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) => {
await page.route('**/api/policy/exceptions**', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
...mockExceptions.items[0],
id: 'exc-new-1',
exceptionId: 'exc-created',
status: 'pending_review',
}),
});
} else {
route.continue();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockExceptions),
});
});
// Block authority
page.route('https://authority.local/**', (route) => route.abort());
await page.route('**/api/policy/exceptions/**/transition', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...mockExceptions.items[0], status: 'approved' }),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
}
test.describe.skip('Risk Dashboard - Budget View' /* TODO: Budget view not yet implemented */, () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// Ignore storage errors in restricted contexts.
}
await setupMockRoutes(page);
});
(window as any).__stellaopsTestSession = session;
}, stubSession);
test('displays budget burn-up chart', async ({ page }) => {
await page.goto('/security/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('/security/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('/security/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('/security/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('/security/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
// Risk retired value (85)
await expect(page.getByText('85')).toBeVisible();
});
await setupMockRoutes(page);
});
test.describe.skip('Risk Dashboard - Verdict View' /* TODO: Verdict view not yet implemented */, () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
test('renders budget, verdict, diff, and exception widgets', async ({ page }) => {
await page.goto('/security/risk');
await setupMockRoutes(page);
});
test('displays verdict badge', async ({ page }) => {
await page.goto('/security/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('/security/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('/security/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('/security/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('/security/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
});
await expect(page.locator('[data-testid="budget-widget"]')).toBeVisible();
await expect(page.locator('[data-testid="verdict-widget"]')).toBeVisible();
await expect(page.locator('[data-testid="diff-widget"]')).toBeVisible();
await expect(page.locator('[data-testid="exception-widget"]')).toBeVisible();
});
test.describe.skip('Risk Dashboard - Exception Workflow' /* TODO: Exception workflow in risk dashboard not yet implemented */, () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
test('shows budget chart and KPI values', async ({ page }) => {
await page.goto('/security/risk');
await setupMockRoutes(page);
});
test('displays active exceptions', async ({ page }) => {
await page.goto('/security/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('/security/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('/security/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('/security/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();
});
await expect(page.locator('[data-testid="budget-chart"]')).toBeVisible();
await expect(page.getByText('Budget Limit')).toBeVisible();
await expect(page.getByText('Actual Risk Points')).toBeVisible();
await expect(page.getByText('Headroom')).toBeVisible();
});
test.describe.skip('Risk Dashboard - Side-by-Side Diff' /* TODO: Side-by-side diff not yet implemented */, () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
test('shows verdict badge and why summary from backend response', async ({ page }) => {
await page.goto('/security/risk');
await setupMockRoutes(page);
});
test('displays before and after states', async ({ page }) => {
await page.goto('/security/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('/security/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();
}
});
await expect(page.locator('[data-testid="verdict-badge"]')).toBeVisible();
await expect(page.locator('[data-testid="verdict-summary"]')).toBeVisible();
await expect(
page.locator('[data-testid="verdict-summary"]').getByText(/high severity vulnerabilities/i)
).toBeVisible();
});
test.describe.skip('Risk Dashboard - Responsive Design' /* TODO: Responsive design tests depend on unimplemented features */, () => {
test('adapts to tablet viewport', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
test('opens create exception modal and validates required fields', async ({ page }) => {
await page.goto('/security/risk');
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, mockSession);
await page.getByRole('button', { name: /new exception/i }).click();
await expect(page.getByRole('heading', { name: 'Create Exception' })).toBeVisible();
await setupMockRoutes(page);
const createButton = page.locator('.modal-footer .btn-primary');
await expect(createButton).toBeDisabled();
await page.goto('/security/risk');
await expect(page.getByRole('heading', { name: /risk/i })).toBeVisible({
timeout: 10000,
});
await page.getByLabel('Title *').fill('Temporary OpenSSL exception');
await page.getByLabel('Justification *').fill('Compensating controls are active while patch rollout completes.');
// 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('/security/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();
});
await expect(createButton).toBeEnabled();
});
test('keeps dashboard usable across tablet and desktop layouts', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/security/risk');
await expect(page.locator('.risk-dashboard')).toBeVisible();
await expect(page.locator('[data-testid="budget-widget"]')).toBeVisible();
await page.setViewportSize({ width: 1440, height: 900 });
await page.reload();
await expect(page.locator('.risk-dashboard')).toBeVisible();
await expect(page.locator('[data-testid="verdict-widget"]')).toBeVisible();
});

View File

@@ -1,6 +1,20 @@
import { expect, test } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const stubSession = {
subjectId: 'score-e2e-user',
tenant: 'tenant-default',
scopes: [
'ui.read',
'scanner:read',
'sbom:read',
'advisory:read',
'vex:read',
'exception:read',
'findings:read',
'vuln:view',
'policy:read',
],
};
const mockConfig = {
authority: {
@@ -11,8 +25,7 @@ const mockConfig = {
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',
scope: 'openid profile email ui.read scanner:read sbom:read advisory:read vex:read exception:read findings:read vuln:view policy:read',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
@@ -20,67 +33,44 @@ const mockConfig = {
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
policy: 'https://policy.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
gateway: 'https://gateway.local',
},
quickstartMode: true,
};
const mockFindings = [
{
id: 'CVE-2024-1234@pkg:npm/lodash@4.17.20',
advisoryId: 'CVE-2024-1234',
packageName: 'lodash',
packageVersion: '4.17.20',
severity: 'critical',
status: 'open',
},
{
id: 'CVE-2024-5678@pkg:npm/express@4.18.0',
advisoryId: 'CVE-2024-5678',
packageName: 'express',
packageVersion: '4.18.0',
severity: 'high',
status: 'open',
},
{
id: 'GHSA-abc123@pkg:pypi/requests@2.25.0',
advisoryId: 'GHSA-abc123',
packageName: 'requests',
packageVersion: '2.25.0',
severity: 'medium',
status: 'open',
},
];
const mockScoreResults = [
{
findingId: 'CVE-2024-1234@pkg:npm/lodash@4.17.20',
findingId: 'CVE-2026-8001@pkg:oci/backend-api@2.5.0-hard-fail-anchored-scan-e2e',
score: 92,
bucket: 'ActNow',
inputs: { rch: 0.9, rts: 0.8, bkp: 0, xpl: 0.9, src: 0.8, mit: 0.1 },
weights: { rch: 0.3, rts: 0.25, bkp: 0.15, xpl: 0.15, src: 0.1, mit: 0.05 },
flags: ['live-signal', 'proven-path'],
explanations: ['High reachability via static analysis', 'Active runtime signals detected'],
flags: ['live-signal', 'proven-path', 'anchored', 'hard-fail'],
explanations: [
'High reachability via static analysis',
'Active runtime signals detected',
],
caps: { speculativeCap: false, notAffectedCap: false, runtimeFloor: true },
policyDigest: 'sha256:abc123',
calculatedAt: new Date().toISOString(),
calculatedAt: '2026-02-26T10:00:00Z',
},
{
findingId: 'CVE-2024-5678@pkg:npm/express@4.18.0',
findingId: 'CVE-2026-8002@pkg:oci/worker-api@1.8.3-anchored-scan-e2e',
score: 78,
bucket: 'ScheduleNext',
inputs: { rch: 0.7, rts: 0.3, bkp: 0, xpl: 0.6, src: 0.8, mit: 0 },
weights: { rch: 0.3, rts: 0.25, bkp: 0.15, xpl: 0.15, src: 0.1, mit: 0.05 },
flags: ['proven-path'],
flags: ['proven-path', 'anchored'],
explanations: ['Verified call path to vulnerable function'],
caps: { speculativeCap: false, notAffectedCap: false, runtimeFloor: false },
policyDigest: 'sha256:abc123',
calculatedAt: new Date().toISOString(),
calculatedAt: '2026-02-26T10:00:00Z',
},
{
findingId: 'GHSA-abc123@pkg:pypi/requests@2.25.0',
findingId: 'CVE-2026-8003@pkg:oci/frontend-ui@4.0.1-scan-e2e',
score: 45,
bucket: 'Investigate',
inputs: { rch: 0.4, rts: 0, bkp: 0, xpl: 0.5, src: 0.6, mit: 0 },
@@ -89,19 +79,67 @@ const mockScoreResults = [
explanations: ['Reachability unconfirmed'],
caps: { speculativeCap: true, notAffectedCap: false, runtimeFloor: false },
policyDigest: 'sha256:abc123',
calculatedAt: new Date().toISOString(),
calculatedAt: '2026-02-26T10:00:00Z',
},
];
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);
const mockHistory = {
findingId: 'CVE-2026-8001@pkg:oci/backend-api@2.5.0-hard-fail-anchored-scan-e2e',
history: [
{
score: 65,
bucket: 'Investigate',
policyDigest: 'sha256:abc123',
calculatedAt: '2026-02-01T10:00:00Z',
trigger: 'scheduled',
changedFactors: [],
},
{
score: 78,
bucket: 'ScheduleNext',
policyDigest: 'sha256:abc123',
calculatedAt: '2026-02-10T10:00:00Z',
trigger: 'evidence_update',
changedFactors: ['rts'],
},
{
score: 92,
bucket: 'ActNow',
policyDigest: 'sha256:abc123',
calculatedAt: '2026-02-20T10:00:00Z',
trigger: 'evidence_update',
changedFactors: ['rch'],
},
],
pagination: {
hasMore: false,
},
};
async function setupMockRoutes(page: import('@playwright/test').Page): Promise<void> {
await page.route('https://authority.local/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
issuer: 'https://authority.local',
authorization_endpoint: 'https://authority.local/connect/authorize',
token_endpoint: 'https://authority.local/connect/token',
}),
})
);
await page.route('**/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
issuer: 'https://authority.local',
authorization_endpoint: 'https://authority.local/connect/authorize',
token_endpoint: 'https://authority.local/connect/token',
}),
})
);
await page.route('**/config.json', (route) =>
route.fulfill({
@@ -111,426 +149,114 @@ test.beforeEach(async ({ page }) => {
})
);
await page.route('**/api/findings**', (route) =>
await page.route('**/api/compare/baselines/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: mockFindings, total: mockFindings.length }),
body: JSON.stringify({
selectedDigest: '',
selectionReason: 'No baseline selected for test fixture',
alternatives: [],
autoSelectEnabled: true,
}),
})
);
await page.route('**/api/scores/batch', (route) =>
await page.route('**/api/v1/findings/scores', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: mockScoreResults }),
body: JSON.stringify({
results: mockScoreResults,
summary: {
total: 3,
byBucket: {
ActNow: 1,
ScheduleNext: 1,
Investigate: 1,
Watchlist: 0,
},
averageScore: 71.6667,
calculationTimeMs: 8,
},
policyDigest: 'sha256:abc123',
calculatedAt: '2026-02-26T10:00:00Z',
}),
})
);
await page.route('**/api/v1/findings/**/score-history**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockHistory),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
}
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// Ignore storage errors in restricted contexts.
}
(window as any).__stellaopsTestSession = session;
}, stubSession);
await setupMockRoutes(page);
});
test.describe.skip('Score Pill Component' /* TODO: Score pill not yet integrated into security findings page */, () => {
test('displays score pills with correct bucket colors', async ({ page }) => {
await page.goto('/findings');
await expect(page.getByRole('heading', { name: /findings/i })).toBeVisible({ timeout: 10000 });
test('renders score pills in detail findings view', async ({ page }) => {
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
// Wait for scores to load
await page.waitForResponse('**/api/scores/batch');
// Check Act Now score (92) has red styling
const actNowPill = page.locator('stella-score-pill').filter({ hasText: '92' });
await expect(actNowPill).toBeVisible();
await expect(actNowPill).toHaveCSS('background-color', 'rgb(220, 38, 38)'); // #DC2626
// Check Schedule Next score (78) has amber styling
const scheduleNextPill = page.locator('stella-score-pill').filter({ hasText: '78' });
await expect(scheduleNextPill).toBeVisible();
// Check Investigate score (45) has blue styling
const investigatePill = page.locator('stella-score-pill').filter({ hasText: '45' });
await expect(investigatePill).toBeVisible();
});
test('score pill shows tooltip on hover', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const scorePill = page.locator('stella-score-pill').first();
await scorePill.hover();
// Tooltip should appear with bucket name
await expect(page.getByRole('tooltip')).toBeVisible();
await expect(page.getByRole('tooltip')).toContainText(/act now|schedule next|investigate|watchlist/i);
});
test('score pill is keyboard accessible', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const scorePill = page.locator('stella-score-pill').first();
await scorePill.focus();
// Should have focus ring
await expect(scorePill).toBeFocused();
// Enter key should trigger click
await page.keyboard.press('Enter');
// Score breakdown popover should appear
await expect(page.locator('stella-score-breakdown-popover')).toBeVisible();
});
await expect(page.locator('.findings-list')).toBeVisible();
await expect(page.locator('stella-score-pill')).toHaveCount(3);
await expect(page.locator('stella-score-pill').filter({ hasText: '92' })).toBeVisible();
await expect(page.locator('stella-score-pill').filter({ hasText: '78' })).toBeVisible();
await expect(page.locator('stella-score-pill').filter({ hasText: '45' })).toBeVisible();
});
test.describe.skip('Score Breakdown Popover' /* TODO: Score breakdown popover not yet integrated into security findings page */, () => {
test('opens on score pill click and shows all dimensions', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
test('opens score breakdown popover with dimensions and factors', async ({ page }) => {
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
// Click on the first score pill
await page.locator('stella-score-pill').first().click();
await page.locator('stella-score-pill').filter({ hasText: '92' }).click();
const popover = page.locator('stella-score-breakdown-popover');
await expect(popover).toBeVisible();
// Should show all 6 dimensions
await expect(popover.getByText('Reachability')).toBeVisible();
await expect(popover.getByText('Runtime Signals')).toBeVisible();
await expect(popover.getByText('Backport')).toBeVisible();
await expect(popover.getByText('Exploitability')).toBeVisible();
await expect(popover.getByText('Source Trust')).toBeVisible();
await expect(popover.getByText('Mitigations')).toBeVisible();
});
test('shows flags in popover', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on score with live-signal and proven-path flags
await page.locator('stella-score-pill').filter({ hasText: '92' }).click();
const popover = page.locator('stella-score-breakdown-popover');
await expect(popover).toBeVisible();
// Should show flag badges
await expect(popover.locator('stella-score-badge[type="live-signal"]')).toBeVisible();
await expect(popover.locator('stella-score-badge[type="proven-path"]')).toBeVisible();
});
test('shows guardrails when applied', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on score with runtime floor applied
await page.locator('stella-score-pill').filter({ hasText: '92' }).click();
const popover = page.locator('stella-score-breakdown-popover');
await expect(popover).toBeVisible();
// Should show runtime floor guardrail
await expect(popover.getByText(/runtime floor/i)).toBeVisible();
});
test('closes on click outside', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
await page.locator('stella-score-pill').first().click();
await expect(page.locator('stella-score-breakdown-popover')).toBeVisible();
// Click outside the popover
await page.locator('body').click({ position: { x: 10, y: 10 } });
await expect(page.locator('stella-score-breakdown-popover')).toBeHidden();
});
test('closes on Escape key', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
await page.locator('stella-score-pill').first().click();
await expect(page.locator('stella-score-breakdown-popover')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('stella-score-breakdown-popover')).toBeHidden();
});
await expect(page.getByRole('dialog', { name: /Evidence score breakdown/i })).toBeVisible();
await expect(page.getByText('Dimensions')).toBeVisible();
await expect(page.getByText('High reachability via static analysis')).toBeVisible();
await expect(page.locator('[aria-label=\"Score flags\"] > div > div')).toHaveCount(4);
});
test.describe.skip('Score Badge Component' /* TODO: Score badge not yet integrated into security findings page */, () => {
test('displays all flag types correctly', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
test('filters findings by bucket chips', async ({ page }) => {
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
// Check for live-signal badge (green)
const liveSignalBadge = page.locator('stella-score-badge[type="live-signal"]').first();
await expect(liveSignalBadge).toBeVisible();
await page.locator('.bucket-summary .bucket-chip').filter({ hasText: /^Act Now/ }).click();
// Check for proven-path badge (blue)
const provenPathBadge = page.locator('stella-score-badge[type="proven-path"]').first();
await expect(provenPathBadge).toBeVisible();
// Check for speculative badge (orange)
const speculativeBadge = page.locator('stella-score-badge[type="speculative"]').first();
await expect(speculativeBadge).toBeVisible();
});
test('shows tooltip on badge hover', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const badge = page.locator('stella-score-badge[type="live-signal"]').first();
await badge.hover();
await expect(page.getByRole('tooltip')).toBeVisible();
await expect(page.getByRole('tooltip')).toContainText(/runtime signals/i);
});
await expect(page.locator('tbody tr.finding-row:visible')).toHaveCount(1);
await expect(page.locator('tbody tr.finding-row:visible stella-score-pill')).toContainText('92');
});
test.describe.skip('Findings List Score Integration' /* TODO: Score integration not yet in security findings page */, () => {
test('loads scores automatically when findings load', async ({ page }) => {
await page.goto('/findings');
test('loads score history chart from API for active finding', async ({ page }) => {
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
// Wait for both findings and scores to load
await page.waitForResponse('**/api/findings**');
const scoresResponse = await page.waitForResponse('**/api/scores/batch');
await page.locator('stella-score-pill').filter({ hasText: '92' }).click();
expect(scoresResponse.ok()).toBeTruthy();
// All score pills should be visible
const scorePills = page.locator('stella-score-pill');
await expect(scorePills).toHaveCount(3);
});
test('filters findings by bucket', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on Act Now filter chip
await page.getByRole('button', { name: /act now/i }).click();
// Should only show Act Now findings
const visiblePills = page.locator('stella-score-pill:visible');
await expect(visiblePills).toHaveCount(1);
await expect(visiblePills.first()).toContainText('92');
});
test('filters findings by flag', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on Live Signal filter checkbox
await page.getByLabel(/live signal/i).check();
// Should only show findings with live-signal flag
const visibleRows = page.locator('table tbody tr:visible');
await expect(visibleRows).toHaveCount(1);
});
test('sorts findings by score', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
// Click on Score column header to sort
await page.getByRole('columnheader', { name: /score/i }).click();
// First row should have highest score
const firstPill = page.locator('table tbody tr').first().locator('stella-score-pill');
await expect(firstPill).toContainText('92');
// Click again to reverse sort
await page.getByRole('columnheader', { name: /score/i }).click();
// First row should now have lowest score
const firstPillReversed = page.locator('table tbody tr').first().locator('stella-score-pill');
await expect(firstPillReversed).toContainText('45');
});
await expect(page.locator('[data-testid="score-history-panel"]')).toBeVisible();
await expect(page.locator('[data-testid="score-history-chart"]')).toBeVisible();
});
test.describe.skip('Bulk Triage View' /* TODO: Bulk triage view not yet implemented as a separate page */, () => {
test('shows bucket summary cards with correct counts', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
test('sorts by score ascending and descending', async ({ page }) => {
await page.goto('/security/findings?view=detail&scanId=scan-e2e');
// Check bucket cards
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await expect(actNowCard).toContainText('1');
const scoreHeader = page.locator('th.col-score');
const scheduleNextCard = page.locator('.bucket-card').filter({ hasText: /schedule next/i });
await expect(scheduleNextCard).toContainText('1');
await scoreHeader.click();
await expect(page.locator('tbody tr').first().locator('stella-score-pill')).toContainText('45');
const investigateCard = page.locator('.bucket-card').filter({ hasText: /investigate/i });
await expect(investigateCard).toContainText('1');
});
test('select all in bucket selects correct findings', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Click Select All on Act Now bucket
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
// Action bar should appear with correct count
await expect(page.locator('.action-bar.visible')).toBeVisible();
await expect(page.locator('.selection-count')).toContainText('1');
});
test('bulk acknowledge action works', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Mock acknowledge endpoint
await page.route('**/api/findings/acknowledge', (route) =>
route.fulfill({ status: 200, body: JSON.stringify({ success: true }) })
);
// Select a finding
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
// Click acknowledge
await page.getByRole('button', { name: /acknowledge/i }).click();
// Progress overlay should appear
await expect(page.locator('.progress-overlay')).toBeVisible();
// Wait for completion
await expect(page.locator('.progress-overlay')).toBeHidden({ timeout: 5000 });
// Selection should be cleared
await expect(page.locator('.action-bar.visible')).toBeHidden();
});
test('bulk suppress action opens modal', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Select a finding
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
// Click suppress
await page.getByRole('button', { name: /suppress/i }).click();
// Modal should appear
const modal = page.locator('.modal').filter({ hasText: /suppress/i });
await expect(modal).toBeVisible();
await expect(modal.getByLabel(/reason/i)).toBeVisible();
});
test('bulk assign action opens modal', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Select a finding
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
// Click assign
await page.getByRole('button', { name: /assign/i }).click();
// Modal should appear
const modal = page.locator('.modal').filter({ hasText: /assign/i });
await expect(modal).toBeVisible();
await expect(modal.getByLabel(/assignee|email/i)).toBeVisible();
});
});
test.describe.skip('Score History Chart' /* TODO: Score history chart not yet integrated */, () => {
const mockHistory = [
{ score: 65, bucket: 'Investigate', policyDigest: 'sha256:abc', calculatedAt: '2025-01-01T10:00:00Z', trigger: 'scheduled', changedFactors: [] },
{ score: 72, bucket: 'ScheduleNext', policyDigest: 'sha256:abc', calculatedAt: '2025-01-05T10:00:00Z', trigger: 'evidence_update', changedFactors: ['xpl'] },
{ score: 85, bucket: 'ScheduleNext', policyDigest: 'sha256:abc', calculatedAt: '2025-01-10T10:00:00Z', trigger: 'evidence_update', changedFactors: ['rts'] },
{ score: 92, bucket: 'ActNow', policyDigest: 'sha256:abc', calculatedAt: '2025-01-14T10:00:00Z', trigger: 'evidence_update', changedFactors: ['rch'] },
];
test.beforeEach(async ({ page }) => {
await page.route('**/api/findings/*/history', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ entries: mockHistory }),
})
);
});
test('renders chart with data points', async ({ page }) => {
await page.goto('/findings/CVE-2024-1234@pkg:npm/lodash@4.17.20');
await page.waitForResponse('**/api/findings/*/history');
const chart = page.locator('stella-score-history-chart');
await expect(chart).toBeVisible();
// Should have data points
const dataPoints = chart.locator('.data-point, circle');
await expect(dataPoints).toHaveCount(4);
});
test('shows tooltip on data point hover', async ({ page }) => {
await page.goto('/findings/CVE-2024-1234@pkg:npm/lodash@4.17.20');
await page.waitForResponse('**/api/findings/*/history');
const chart = page.locator('stella-score-history-chart');
const dataPoint = chart.locator('.data-point, circle').first();
await dataPoint.hover();
await expect(page.locator('.chart-tooltip')).toBeVisible();
await expect(page.locator('.chart-tooltip')).toContainText(/score/i);
});
test('date range selector filters history', async ({ page }) => {
await page.goto('/findings/CVE-2024-1234@pkg:npm/lodash@4.17.20');
await page.waitForResponse('**/api/findings/*/history');
const chart = page.locator('stella-score-history-chart');
// Select 7 day range
await chart.getByRole('button', { name: /7 days/i }).click();
// Should filter to recent entries
const dataPoints = chart.locator('.data-point:visible, circle:visible');
const count = await dataPoints.count();
expect(count).toBeLessThanOrEqual(4);
});
});
test.describe.skip('Accessibility' /* TODO: Accessibility tests depend on score components not yet integrated */, () => {
test('score pill has correct ARIA attributes', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const scorePill = page.locator('stella-score-pill').first();
await expect(scorePill).toHaveAttribute('role', 'status');
await expect(scorePill).toHaveAttribute('aria-label', /score.*92.*act now/i);
});
test('score badge has correct ARIA attributes', async ({ page }) => {
await page.goto('/findings');
await page.waitForResponse('**/api/scores/batch');
const badge = page.locator('stella-score-badge').first();
await expect(badge).toHaveAttribute('role', 'img');
await expect(badge).toHaveAttribute('aria-label', /.+/);
});
test('bucket summary has correct ARIA label', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
const bucketSummary = page.locator('.bucket-summary');
await expect(bucketSummary).toHaveAttribute('aria-label', 'Findings by priority');
});
test('action bar has toolbar role', async ({ page }) => {
await page.goto('/findings/triage');
await page.waitForResponse('**/api/scores/batch');
// Select a finding to show action bar
const actNowCard = page.locator('.bucket-card').filter({ hasText: /act now/i });
await actNowCard.getByRole('button', { name: /select all/i }).click();
const actionBar = page.locator('.action-bar');
await expect(actionBar).toHaveAttribute('role', 'toolbar');
});
await scoreHeader.click();
await expect(page.locator('tbody tr').first().locator('stella-score-pill')).toContainText('92');
});

View File

@@ -76,14 +76,14 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
})
);
await page.route('https://authority.local/**', (route) => route.abort());
await page.route('**/api/v1/orchestrator/workflows/**', (route) =>
await page.route('**/api/v1/jobengine/workflows/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockWorkflow),
})
);
await page.route('**/api/v1/orchestrator/workflows/*/snapshots', (route) =>
await page.route('**/api/v1/jobengine/workflows/*/snapshots', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
@@ -96,7 +96,7 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
});
test('workflow visualization page loads and renders DAG nodes', async ({ page }) => {
await page.goto('/release-orchestrator');
await page.goto('/release-jobengine');
await page.waitForLoadState('domcontentloaded');
// Inject workflow DAG visualization DOM
@@ -142,7 +142,7 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
});
test('time-travel controls render with playback buttons', async ({ page }) => {
await page.goto('/release-orchestrator');
await page.goto('/release-jobengine');
await page.waitForLoadState('domcontentloaded');
await page.evaluate((snapshots) => {
@@ -185,7 +185,7 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
});
test('time-travel step forward advances snapshot index', async ({ page }) => {
await page.goto('/release-orchestrator');
await page.goto('/release-jobengine');
await page.waitForLoadState('domcontentloaded');
await page.evaluate((snapshots) => {
@@ -271,7 +271,7 @@ test.describe('WEB-FEAT-003: Workflow Visualization with Time-Travel Controls',
});
test('DAG node selection highlights related nodes', async ({ page }) => {
await page.goto('/release-orchestrator');
await page.goto('/release-jobengine');
await page.waitForLoadState('domcontentloaded');
await page.evaluate((wf) => {