todays product advirories implemented
This commit is contained in:
215
src/Web/StellaOps.Web/tests/e2e/binary-diff-panel.spec.ts
Normal file
215
src/Web/StellaOps.Web/tests/e2e/binary-diff-panel.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// binary-diff-panel.spec.ts
|
||||
// Sprint: SPRINT_20260117_018_FE_ux_components
|
||||
// Task: UXC-008 - Integration tests with Playwright
|
||||
// Description: Playwright e2e tests for Binary-Diff Panel component
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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 findings:read binary:read',
|
||||
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,
|
||||
};
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
test.describe('Binary-Diff Panel Component', () => {
|
||||
test('renders header with base and candidate info', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify header shows base and candidate
|
||||
await expect(page.getByText('Base')).toBeVisible();
|
||||
await expect(page.getByText('Candidate')).toBeVisible();
|
||||
|
||||
// Verify diff stats
|
||||
await expect(page.locator('.diff-stats')).toBeVisible();
|
||||
});
|
||||
|
||||
test('scope selector switches between file, section, function', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find scope selector buttons
|
||||
const fileBtn = page.getByRole('button', { name: /File/i });
|
||||
const sectionBtn = page.getByRole('button', { name: /Section/i });
|
||||
const functionBtn = page.getByRole('button', { name: /Function/i });
|
||||
|
||||
await expect(fileBtn).toBeVisible();
|
||||
await expect(sectionBtn).toBeVisible();
|
||||
await expect(functionBtn).toBeVisible();
|
||||
|
||||
// Click section scope
|
||||
await sectionBtn.click();
|
||||
await expect(sectionBtn).toHaveClass(/active/);
|
||||
|
||||
// Click function scope
|
||||
await functionBtn.click();
|
||||
await expect(functionBtn).toHaveClass(/active/);
|
||||
|
||||
// Click file scope
|
||||
await fileBtn.click();
|
||||
await expect(fileBtn).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('scope selection updates diff view', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Select an entry in the tree
|
||||
const treeItem = page.locator('.tree-item').first();
|
||||
await treeItem.click();
|
||||
|
||||
// Verify selection state
|
||||
await expect(treeItem).toHaveClass(/selected/);
|
||||
|
||||
// Verify diff view updates (footer shows hashes)
|
||||
await expect(page.locator('.diff-footer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('show only changed toggle filters unchanged entries', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find the toggle
|
||||
const toggle = page.getByLabel(/Show only changed/i);
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Count items before toggle
|
||||
const itemsBefore = await page.locator('.tree-item').count();
|
||||
|
||||
// Enable toggle
|
||||
await toggle.check();
|
||||
await expect(toggle).toBeChecked();
|
||||
|
||||
// Items may be filtered (or same count if all changed)
|
||||
const itemsAfter = await page.locator('.tree-item').count();
|
||||
expect(itemsAfter).toBeLessThanOrEqual(itemsBefore);
|
||||
});
|
||||
|
||||
test('opcodes/decompiled toggle changes view mode', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find the toggle
|
||||
const toggle = page.getByLabel(/Opcodes|Decompiled/i);
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Toggle and verify label changes
|
||||
const initialText = await toggle.locator('..').textContent();
|
||||
await toggle.click();
|
||||
const newText = await toggle.locator('..').textContent();
|
||||
|
||||
// Text should change between Opcodes and Decompiled
|
||||
expect(initialText).not.toEqual(newText);
|
||||
});
|
||||
|
||||
test('export signed diff button is functional', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find export button
|
||||
const exportBtn = page.getByRole('button', { name: /Export Signed Diff/i });
|
||||
await expect(exportBtn).toBeVisible();
|
||||
|
||||
// Click and verify action
|
||||
await exportBtn.click();
|
||||
|
||||
// Should trigger download or modal (implementation dependent)
|
||||
// At minimum, button should be clickable without error
|
||||
});
|
||||
|
||||
test('tree navigation supports keyboard', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Focus first tree item
|
||||
const firstItem = page.locator('.tree-item').first();
|
||||
await firstItem.focus();
|
||||
|
||||
// Press Enter to select
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(firstItem).toHaveClass(/selected/);
|
||||
});
|
||||
|
||||
test('diff view shows side-by-side comparison', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify side-by-side columns
|
||||
await expect(page.locator('.diff-header-row')).toBeVisible();
|
||||
await expect(page.locator('.line-base').first()).toBeVisible();
|
||||
await expect(page.locator('.line-candidate').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('change indicators show correct colors', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Check for change type classes on tree items
|
||||
const addedItem = page.locator('.tree-item.change-added');
|
||||
const removedItem = page.locator('.tree-item.change-removed');
|
||||
const modifiedItem = page.locator('.tree-item.change-modified');
|
||||
|
||||
// At least one type should exist in a real diff
|
||||
const hasChanges =
|
||||
(await addedItem.count()) > 0 ||
|
||||
(await removedItem.count()) > 0 ||
|
||||
(await modifiedItem.count()) > 0;
|
||||
|
||||
expect(hasChanges).toBeTruthy();
|
||||
});
|
||||
|
||||
test('hash display in footer shows base and candidate hashes', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Select an entry
|
||||
await page.locator('.tree-item').first().click();
|
||||
|
||||
// Verify footer hash display
|
||||
await expect(page.getByText('Base Hash:')).toBeVisible();
|
||||
await expect(page.getByText('Candidate Hash:')).toBeVisible();
|
||||
});
|
||||
});
|
||||
288
src/Web/StellaOps.Web/tests/e2e/filter-strip.spec.ts
Normal file
288
src/Web/StellaOps.Web/tests/e2e/filter-strip.spec.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// filter-strip.spec.ts
|
||||
// Sprint: SPRINT_20260117_018_FE_ux_components
|
||||
// Task: UXC-008 - Integration tests with Playwright
|
||||
// Description: Playwright e2e tests for Filter Strip component with determinism
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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 findings:read vuln:view',
|
||||
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,
|
||||
};
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
test.describe('Filter Strip Component', () => {
|
||||
test('renders all precedence toggles in correct order', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify precedence order: OpenVEX, Patch Proof, Reachability, EPSS
|
||||
const toggles = page.locator('.precedence-toggle');
|
||||
await expect(toggles).toHaveCount(4);
|
||||
|
||||
await expect(toggles.nth(0)).toContainText('OpenVEX');
|
||||
await expect(toggles.nth(1)).toContainText('Patch Proof');
|
||||
await expect(toggles.nth(2)).toContainText('Reachability');
|
||||
await expect(toggles.nth(3)).toContainText('EPSS');
|
||||
});
|
||||
|
||||
test('precedence toggles can be activated and deactivated', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const openvexToggle = page.getByRole('button', { name: /OpenVEX/i });
|
||||
await expect(openvexToggle).toBeVisible();
|
||||
|
||||
// Toggle should be active by default
|
||||
await expect(openvexToggle).toHaveClass(/active/);
|
||||
|
||||
// Click to deactivate
|
||||
await openvexToggle.click();
|
||||
await expect(openvexToggle).not.toHaveClass(/active/);
|
||||
|
||||
// Click to reactivate
|
||||
await openvexToggle.click();
|
||||
await expect(openvexToggle).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('EPSS slider adjusts threshold', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const slider = page.locator('#epss-slider');
|
||||
await expect(slider).toBeVisible();
|
||||
|
||||
// Get initial value display
|
||||
const valueDisplay = page.locator('.epss-value');
|
||||
const initialValue = await valueDisplay.textContent();
|
||||
|
||||
// Move slider
|
||||
await slider.fill('50');
|
||||
|
||||
// Verify value changed
|
||||
const newValue = await valueDisplay.textContent();
|
||||
expect(newValue).toContain('50%');
|
||||
expect(newValue).not.toEqual(initialValue);
|
||||
});
|
||||
|
||||
test('only reachable checkbox filters results', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const checkbox = page.getByLabel(/Only reachable/i);
|
||||
await expect(checkbox).toBeVisible();
|
||||
|
||||
// Initially unchecked
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
|
||||
// Check the box
|
||||
await checkbox.check();
|
||||
await expect(checkbox).toBeChecked();
|
||||
|
||||
// Verify count may change (depends on data)
|
||||
await expect(page.locator('.result-count')).toBeVisible();
|
||||
});
|
||||
|
||||
test('only with patch proof checkbox filters results', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const checkbox = page.getByLabel(/Only with patch proof/i);
|
||||
await expect(checkbox).toBeVisible();
|
||||
|
||||
// Initially unchecked
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
|
||||
// Check the box
|
||||
await checkbox.check();
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('deterministic order toggle is on by default', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const toggle = page.getByRole('button', { name: /Deterministic order/i });
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Should be active by default per UX spec
|
||||
await expect(toggle).toHaveClass(/active/);
|
||||
|
||||
// Should show lock icon
|
||||
await expect(toggle).toContainText('🔒');
|
||||
});
|
||||
|
||||
test('deterministic order toggle can be disabled', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const toggle = page.getByRole('button', { name: /Deterministic order/i });
|
||||
|
||||
// Disable deterministic order
|
||||
await toggle.click();
|
||||
await expect(toggle).not.toHaveClass(/active/);
|
||||
|
||||
// Should show unlock icon
|
||||
await expect(toggle).toContainText('🔓');
|
||||
});
|
||||
|
||||
test('result count updates without page reflow', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const resultCount = page.locator('.result-count');
|
||||
await expect(resultCount).toBeVisible();
|
||||
|
||||
// Get initial count
|
||||
const initialCount = await resultCount.textContent();
|
||||
|
||||
// Toggle a filter
|
||||
const openvexToggle = page.getByRole('button', { name: /OpenVEX/i });
|
||||
await openvexToggle.click();
|
||||
|
||||
// Count should update (may be same or different based on data)
|
||||
await expect(resultCount).toBeVisible();
|
||||
|
||||
// Re-enable
|
||||
await openvexToggle.click();
|
||||
const finalCount = await resultCount.textContent();
|
||||
|
||||
// Should return to original
|
||||
expect(finalCount).toEqual(initialCount);
|
||||
});
|
||||
|
||||
test('deterministic ordering produces consistent results', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Enable deterministic order
|
||||
const toggle = page.getByRole('button', { name: /Deterministic order/i });
|
||||
if (!(await toggle.evaluate((el) => el.classList.contains('active')))) {
|
||||
await toggle.click();
|
||||
}
|
||||
|
||||
// Capture order of findings
|
||||
const findingsFirst = await page.locator('.finding-row, .triage-card').allTextContents();
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Capture order again
|
||||
const findingsSecond = await page.locator('.finding-row, .triage-card').allTextContents();
|
||||
|
||||
// Order should be identical (deterministic)
|
||||
expect(findingsFirst).toEqual(findingsSecond);
|
||||
});
|
||||
|
||||
test('filter strip has proper accessibility attributes', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify toolbar role
|
||||
await expect(page.locator('.filter-strip')).toHaveAttribute('role', 'toolbar');
|
||||
|
||||
// Verify aria-labels
|
||||
await expect(page.locator('[aria-label="Filter precedence"]')).toBeVisible();
|
||||
await expect(page.locator('[aria-label="EPSS threshold"]')).toBeVisible();
|
||||
await expect(page.locator('[aria-label="Additional filters"]')).toBeVisible();
|
||||
|
||||
// Verify aria-pressed on toggles
|
||||
const toggle = page.locator('.precedence-toggle').first();
|
||||
await expect(toggle).toHaveAttribute('aria-pressed');
|
||||
});
|
||||
|
||||
test('filter strip supports keyboard navigation', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Tab through elements
|
||||
await page.keyboard.press('Tab');
|
||||
const focused = page.locator(':focus');
|
||||
|
||||
// Should focus on interactive element
|
||||
await expect(focused).toBeVisible();
|
||||
|
||||
// Continue tabbing
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Should still be navigable
|
||||
await expect(page.locator(':focus')).toBeVisible();
|
||||
});
|
||||
|
||||
test('high contrast mode maintains visibility', async ({ page }) => {
|
||||
// Emulate high contrast
|
||||
await page.emulateMedia({ forcedColors: 'active' });
|
||||
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// All elements should still be visible
|
||||
await expect(page.locator('.precedence-toggle').first()).toBeVisible();
|
||||
await expect(page.locator('.determinism-toggle')).toBeVisible();
|
||||
await expect(page.locator('.result-count')).toBeVisible();
|
||||
});
|
||||
|
||||
test('focus rings are visible on keyboard focus', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Tab to first toggle
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Check focus-visible styling (outline)
|
||||
const focusedElement = page.locator(':focus-visible');
|
||||
await expect(focusedElement).toBeVisible();
|
||||
|
||||
// Verify outline style exists (implementation may vary)
|
||||
const outlineStyle = await focusedElement.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.outline || style.outlineWidth;
|
||||
});
|
||||
expect(outlineStyle).toBeTruthy();
|
||||
});
|
||||
});
|
||||
195
src/Web/StellaOps.Web/tests/e2e/triage-card.spec.ts
Normal file
195
src/Web/StellaOps.Web/tests/e2e/triage-card.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// triage-card.spec.ts
|
||||
// Sprint: SPRINT_20260117_018_FE_ux_components
|
||||
// Task: UXC-008 - Integration tests with Playwright
|
||||
// Description: Playwright e2e tests for Triage Card component
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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 findings:read vuln:view vuln:investigate',
|
||||
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 mockTriageData = {
|
||||
vulnId: 'CVE-2024-1234',
|
||||
packageName: 'lodash',
|
||||
packageVersion: '4.17.20',
|
||||
scope: 'direct',
|
||||
riskScore: 8.5,
|
||||
riskReason: 'High CVSS + Exploited',
|
||||
evidence: [
|
||||
{ type: 'openvex', status: 'verified', value: 'not_affected' },
|
||||
{ type: 'patch-proof', status: 'verified' },
|
||||
{ type: 'reachability', status: 'pending', value: 'analyzing' },
|
||||
{ type: 'epss', status: 'verified', value: 0.67 },
|
||||
],
|
||||
digest: 'sha256:abc123def456789012345678901234567890123456789012345678901234',
|
||||
attestationDigest: 'sha256:attestation123456789012345678901234567890123456789012',
|
||||
};
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
test.describe('Triage Card Component', () => {
|
||||
test('renders vulnerability information correctly', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.getByRole('article', { name: /CVE-2024/ })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify header content
|
||||
await expect(page.getByText('CVE-2024-1234')).toBeVisible();
|
||||
await expect(page.getByText('lodash@4.17.20')).toBeVisible();
|
||||
await expect(page.getByText('direct')).toBeVisible();
|
||||
|
||||
// Verify risk chip
|
||||
const riskChip = page.locator('.risk-chip');
|
||||
await expect(riskChip).toContainText('8.5');
|
||||
});
|
||||
|
||||
test('displays evidence chips with correct status', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.getByRole('article', { name: /CVE-2024/ })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify evidence chips
|
||||
await expect(page.getByRole('button', { name: /OpenVEX/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Patch Proof/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Reachability/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /EPSS/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('action buttons are visible and functional', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.getByRole('article', { name: /CVE-2024/ })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify action buttons
|
||||
await expect(page.getByRole('button', { name: /Explain/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Create task/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Mute/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Export/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Rekor Verify/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('keyboard shortcut V triggers Rekor Verify', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
const card = page.getByRole('article', { name: /CVE-2024/ });
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Focus the card and press V
|
||||
await card.focus();
|
||||
await page.keyboard.press('v');
|
||||
|
||||
// Verify loading state or verification panel appears
|
||||
await expect(
|
||||
page.getByText('Verifying...').or(page.getByText('Verified')).or(page.getByText('Rekor Verification Details'))
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('keyboard shortcut M triggers Mute action', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
const card = page.getByRole('article', { name: /CVE-2024/ });
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Focus the card and press M
|
||||
await card.focus();
|
||||
await page.keyboard.press('m');
|
||||
|
||||
// Verify mute action was triggered (modal or confirmation)
|
||||
// This depends on implementation - checking for any response
|
||||
await expect(page.locator('[role="dialog"]').or(page.getByText(/mute/i))).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('keyboard shortcut E triggers Export action', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
const card = page.getByRole('article', { name: /CVE-2024/ });
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Focus the card and press E
|
||||
await card.focus();
|
||||
await page.keyboard.press('e');
|
||||
|
||||
// Verify export action was triggered
|
||||
await expect(page.locator('[role="dialog"]').or(page.getByText(/export/i))).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Rekor Verify expands verification panel', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.getByRole('article', { name: /CVE-2024/ })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click Rekor Verify button
|
||||
await page.getByRole('button', { name: /Rekor Verify/ }).click();
|
||||
|
||||
// Wait for verification to complete
|
||||
await expect(page.getByText('Rekor Verification Details')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify details are displayed
|
||||
await expect(page.getByText('Subject')).toBeVisible();
|
||||
await expect(page.getByText('Issuer')).toBeVisible();
|
||||
await expect(page.getByText('Timestamp')).toBeVisible();
|
||||
await expect(page.getByText('Rekor Index')).toBeVisible();
|
||||
});
|
||||
|
||||
test('copy buttons work for digest and Rekor entry', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.getByRole('article', { name: /CVE-2024/ })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find and click copy button for digest
|
||||
const copyBtn = page.getByRole('button', { name: /Copy digest/ });
|
||||
await expect(copyBtn).toBeVisible();
|
||||
|
||||
// Click and verify clipboard (mock)
|
||||
await copyBtn.click();
|
||||
// Clipboard API may not be available in test context, but button should be clickable
|
||||
});
|
||||
|
||||
test('evidence chips show tooltips on hover', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.getByRole('article', { name: /CVE-2024/ })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Hover over evidence chip
|
||||
const chip = page.getByRole('button', { name: /OpenVEX/ });
|
||||
await chip.hover();
|
||||
|
||||
// Verify tooltip appears (title attribute)
|
||||
await expect(chip).toHaveAttribute('title');
|
||||
});
|
||||
});
|
||||
293
src/Web/StellaOps.Web/tests/e2e/ux-components-visual.spec.ts
Normal file
293
src/Web/StellaOps.Web/tests/e2e/ux-components-visual.spec.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ux-components-visual.spec.ts
|
||||
// Sprint: SPRINT_20260117_018_FE_ux_components
|
||||
// Task: UXC-008 - Integration tests with Playwright
|
||||
// Description: Visual regression tests for new UX components
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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 findings:read vuln:view binary:read',
|
||||
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,
|
||||
};
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
test.describe('UX Components Visual Regression', () => {
|
||||
test.describe('Triage Card', () => {
|
||||
test('default state screenshot', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.triage-card').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for any animations to complete
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Take screenshot of first triage card
|
||||
await expect(page.locator('.triage-card').first()).toHaveScreenshot('triage-card-default.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
test('hover state screenshot', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
const card = page.locator('.triage-card').first();
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Hover over card
|
||||
await card.hover();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(card).toHaveScreenshot('triage-card-hover.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
test('expanded verification state screenshot', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
const card = page.locator('.triage-card').first();
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click Rekor Verify and wait for expansion
|
||||
await page.getByRole('button', { name: /Rekor Verify/ }).first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Screenshot expanded state
|
||||
await expect(card).toHaveScreenshot('triage-card-expanded.png', {
|
||||
maxDiffPixelRatio: 0.05,
|
||||
});
|
||||
});
|
||||
|
||||
test('risk chip variants screenshot', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.risk-chip').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Screenshot all risk chips
|
||||
const riskChips = page.locator('.risk-chip');
|
||||
for (let i = 0; i < Math.min(4, await riskChips.count()); i++) {
|
||||
await expect(riskChips.nth(i)).toHaveScreenshot(`risk-chip-variant-${i}.png`, {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Filter Strip', () => {
|
||||
test('default state screenshot', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator('.filter-strip')).toHaveScreenshot('filter-strip-default.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
test('with filters active screenshot', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Activate some filters
|
||||
await page.getByLabel(/Only reachable/i).check();
|
||||
await page.locator('#epss-slider').fill('50');
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(page.locator('.filter-strip')).toHaveScreenshot('filter-strip-active.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
test('deterministic toggle states screenshot', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const toggle = page.locator('.determinism-toggle');
|
||||
|
||||
// Active state (default)
|
||||
await expect(toggle).toHaveScreenshot('determinism-toggle-active.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
|
||||
// Inactive state
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(toggle).toHaveScreenshot('determinism-toggle-inactive.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Binary-Diff Panel', () => {
|
||||
test('default state screenshot', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator('.binary-diff-panel')).toHaveScreenshot('binary-diff-panel-default.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
test('scope selector states screenshot', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const scopeSelector = page.locator('.scope-selector');
|
||||
|
||||
// File scope (default)
|
||||
await expect(scopeSelector).toHaveScreenshot('scope-selector-file.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
|
||||
// Section scope
|
||||
await page.getByRole('button', { name: /Section/i }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(scopeSelector).toHaveScreenshot('scope-selector-section.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
|
||||
// Function scope
|
||||
await page.getByRole('button', { name: /Function/i }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(scopeSelector).toHaveScreenshot('scope-selector-function.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
test('tree item change indicators screenshot', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const tree = page.locator('.scope-tree');
|
||||
|
||||
await expect(tree).toHaveScreenshot('diff-tree-items.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
test('diff view lines screenshot', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Select an entry to show diff
|
||||
await page.locator('.tree-item').first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const diffView = page.locator('.diff-view');
|
||||
|
||||
await expect(diffView).toHaveScreenshot('diff-view-lines.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Dark Mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Enable dark mode
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
});
|
||||
|
||||
test('triage card dark mode screenshot', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.triage-card').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator('.triage-card').first()).toHaveScreenshot('triage-card-dark.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
test('filter strip dark mode screenshot', async ({ page }) => {
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator('.filter-strip')).toHaveScreenshot('filter-strip-dark.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
|
||||
test('binary diff panel dark mode screenshot', async ({ page }) => {
|
||||
await page.goto('/binary/diff');
|
||||
await expect(page.locator('.binary-diff-panel')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator('.binary-diff-panel')).toHaveScreenshot('binary-diff-panel-dark.png', {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Responsive', () => {
|
||||
test('filter strip mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.filter-strip')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator('.filter-strip')).toHaveScreenshot('filter-strip-mobile.png', {
|
||||
maxDiffPixelRatio: 0.05,
|
||||
});
|
||||
});
|
||||
|
||||
test('triage card mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/triage/findings');
|
||||
await expect(page.locator('.triage-card').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(page.locator('.triage-card').first()).toHaveScreenshot('triage-card-mobile.png', {
|
||||
maxDiffPixelRatio: 0.05,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user