audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
360
src/Web/StellaOps.Web/e2e/quiet-triage-workflow.e2e.spec.ts
Normal file
360
src/Web/StellaOps.Web/e2e/quiet-triage-workflow.e2e.spec.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// quiet-triage-workflow.e2e.spec.ts
|
||||
// Sprint: SPRINT_20260106_004_001_FE_quiet_triage_ux_integration
|
||||
// Task: T030 - E2E tests for complete workflow
|
||||
// Description: End-to-end tests for the quiet-by-default triage workflow
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:4200';
|
||||
|
||||
test.describe('Quiet Triage Workflow', () => {
|
||||
let page: Page;
|
||||
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
// Navigate to findings page
|
||||
await page.goto(`${BASE_URL}/triage/findings`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test.describe('Lane Toggle', () => {
|
||||
test('should default to Quiet lane showing only actionable findings', async () => {
|
||||
// Verify Quiet lane is active by default
|
||||
const quietButton = page.locator('app-triage-lane-toggle button:has-text("Actionable")');
|
||||
await expect(quietButton).toHaveClass(/lane-toggle__btn--active/);
|
||||
|
||||
// Verify no gated findings are visible
|
||||
const gatedBadges = page.locator('.gated-badge');
|
||||
await expect(gatedBadges).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('should toggle to Review lane with single click', async () => {
|
||||
// Click Review button
|
||||
const reviewButton = page.locator('app-triage-lane-toggle button:has-text("Review")');
|
||||
await reviewButton.click();
|
||||
|
||||
// Verify Review lane is now active
|
||||
await expect(reviewButton).toHaveClass(/lane-toggle__btn--active/);
|
||||
|
||||
// Verify gated findings are now visible (if any exist)
|
||||
const findingCards = page.locator('.finding-card');
|
||||
const count = await findingCards.count();
|
||||
if (count > 0) {
|
||||
// All visible findings should be gated
|
||||
const gatedCards = page.locator('.finding-card--gated');
|
||||
await expect(gatedCards).toHaveCount(count);
|
||||
}
|
||||
});
|
||||
|
||||
test('should support Q keyboard shortcut for Quiet lane', async () => {
|
||||
// First switch to Review
|
||||
await page.locator('app-triage-lane-toggle button:has-text("Review")').click();
|
||||
|
||||
// Press Q key
|
||||
await page.keyboard.press('q');
|
||||
|
||||
// Verify Quiet lane is active
|
||||
const quietButton = page.locator('app-triage-lane-toggle button:has-text("Actionable")');
|
||||
await expect(quietButton).toHaveClass(/lane-toggle__btn--active/);
|
||||
});
|
||||
|
||||
test('should support R keyboard shortcut for Review lane', async () => {
|
||||
// Press R key
|
||||
await page.keyboard.press('r');
|
||||
|
||||
// Verify Review lane is active
|
||||
const reviewButton = page.locator('app-triage-lane-toggle button:has-text("Review")');
|
||||
await expect(reviewButton).toHaveClass(/lane-toggle__btn--active/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Gated Bucket Chips', () => {
|
||||
test('should display bucket counts when on Review lane', async () => {
|
||||
// Switch to Review lane
|
||||
await page.locator('app-triage-lane-toggle button:has-text("Review")').click();
|
||||
|
||||
// Verify bucket chips are visible
|
||||
const bucketChips = page.locator('app-gated-bucket-chips');
|
||||
await expect(bucketChips).toBeVisible();
|
||||
});
|
||||
|
||||
test('should filter by gating reason when chip is clicked', async () => {
|
||||
// Switch to Review lane
|
||||
await page.locator('app-triage-lane-toggle button:has-text("Review")').click();
|
||||
|
||||
// Click on a bucket chip (if available)
|
||||
const unreachableChip = page.locator('.chip:has-text("Not Reachable")');
|
||||
if (await unreachableChip.isVisible()) {
|
||||
await unreachableChip.click();
|
||||
|
||||
// Verify filter is applied
|
||||
const reasonFilter = page.locator('app-gating-reason-filter select');
|
||||
await expect(reasonFilter).toHaveValue('Unreachable');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Finding Selection and Breadcrumb', () => {
|
||||
test('should display detail panel when finding is selected', async () => {
|
||||
// Click on first finding
|
||||
const firstFinding = page.locator('.finding-card').first();
|
||||
await firstFinding.click();
|
||||
|
||||
// Verify detail panel is visible
|
||||
const detailPanel = page.locator('.detail-panel');
|
||||
await expect(detailPanel).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display provenance breadcrumb in detail panel', async () => {
|
||||
// Select a finding
|
||||
await page.locator('.finding-card').first().click();
|
||||
|
||||
// Verify breadcrumb is visible
|
||||
const breadcrumb = page.locator('app-provenance-breadcrumb');
|
||||
await expect(breadcrumb).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate breadcrumb levels on click', async () => {
|
||||
// Select a finding
|
||||
await page.locator('.finding-card').first().click();
|
||||
|
||||
// Click on layer level in breadcrumb
|
||||
const layerLink = page.locator('.breadcrumb-item:has-text("layer")');
|
||||
if (await layerLink.isVisible()) {
|
||||
await layerLink.click();
|
||||
|
||||
// Verify navigation event (could trigger a modal or navigation)
|
||||
// This depends on implementation
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Decision Drawer', () => {
|
||||
test('should open decision drawer when Record Decision is clicked', async () => {
|
||||
// Select a finding
|
||||
await page.locator('.finding-card').first().click();
|
||||
|
||||
// Click Record Decision button
|
||||
const recordButton = page.locator('button:has-text("Record Decision")');
|
||||
await recordButton.click();
|
||||
|
||||
// Verify drawer is open
|
||||
const drawer = page.locator('app-decision-drawer-enhanced.open, .decision-drawer.open');
|
||||
await expect(drawer).toBeVisible();
|
||||
});
|
||||
|
||||
test('should support A/N/U keyboard shortcuts in drawer', async () => {
|
||||
// Select a finding and open drawer
|
||||
await page.locator('.finding-card').first().click();
|
||||
await page.locator('button:has-text("Record Decision")').click();
|
||||
|
||||
// Wait for drawer to be visible
|
||||
await page.waitForSelector('.decision-drawer.open');
|
||||
|
||||
// Press 'N' for Not Affected
|
||||
await page.keyboard.press('n');
|
||||
|
||||
// Verify Not Affected is selected
|
||||
const notAffectedOption = page.locator('.radio-option:has-text("Not Affected")');
|
||||
await expect(notAffectedOption).toHaveClass(/selected/);
|
||||
});
|
||||
|
||||
test('should close drawer on Escape key', async () => {
|
||||
// Open drawer
|
||||
await page.locator('.finding-card').first().click();
|
||||
await page.locator('button:has-text("Record Decision")').click();
|
||||
await page.waitForSelector('.decision-drawer.open');
|
||||
|
||||
// Press Escape
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify drawer is closed
|
||||
const drawer = page.locator('.decision-drawer.open');
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should show undo toast after submitting decision', async () => {
|
||||
// This test requires mocking the API
|
||||
// Skipping for now as it needs backend integration
|
||||
test.skip();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Evidence Export', () => {
|
||||
test('should display export button in detail panel', async () => {
|
||||
// Select a finding
|
||||
await page.locator('.finding-card').first().click();
|
||||
|
||||
// Verify export button is visible
|
||||
const exportButton = page.locator('app-export-evidence-button');
|
||||
await expect(exportButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show progress indicator when export is triggered', async () => {
|
||||
// Select a finding
|
||||
await page.locator('.finding-card').first().click();
|
||||
|
||||
// Click export button
|
||||
const exportButton = page.locator('app-export-evidence-button button');
|
||||
if (await exportButton.isEnabled()) {
|
||||
await exportButton.click();
|
||||
|
||||
// Verify progress indicator appears (may need API mock)
|
||||
const progress = page.locator('.export-progress');
|
||||
// Progress might appear briefly before completion
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('should have proper ARIA labels on lane toggle', async () => {
|
||||
const laneToggle = page.locator('app-triage-lane-toggle [role="tablist"]');
|
||||
await expect(laneToggle).toHaveAttribute('aria-label', 'Triage lane selection');
|
||||
|
||||
const tabs = page.locator('app-triage-lane-toggle [role="tab"]');
|
||||
const count = await tabs.count();
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test('should support keyboard navigation in findings list', async () => {
|
||||
// Focus on first finding
|
||||
const firstFinding = page.locator('.finding-card').first();
|
||||
await firstFinding.focus();
|
||||
|
||||
// Press Enter to select
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Verify detail panel opens
|
||||
const detailPanel = page.locator('.detail-panel');
|
||||
await expect(detailPanel).toBeVisible();
|
||||
});
|
||||
|
||||
test('should work in high contrast mode', async () => {
|
||||
// Emulate high contrast mode
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
|
||||
// Verify page still renders correctly
|
||||
const pageContent = page.locator('.findings-page');
|
||||
await expect(pageContent).toBeVisible();
|
||||
|
||||
// Verify critical elements have sufficient contrast
|
||||
const severityBadge = page.locator('.severity-badge').first();
|
||||
if (await severityBadge.isVisible()) {
|
||||
// Badge should have border in high contrast mode
|
||||
const styles = await severityBadge.evaluate(el =>
|
||||
window.getComputedStyle(el).borderWidth
|
||||
);
|
||||
// This assertion depends on CSS implementation
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Performance', () => {
|
||||
test('should render findings list within 2 seconds', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await page.goto(`${BASE_URL}/triage/findings`);
|
||||
await page.waitForSelector('.finding-card', { timeout: 2000 });
|
||||
|
||||
const renderTime = Date.now() - startTime;
|
||||
expect(renderTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('should display skeleton UI while loading', async () => {
|
||||
// This test requires slow network simulation
|
||||
await page.route('**/api/v1/triage/**', async route => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto(`${BASE_URL}/triage/findings`);
|
||||
|
||||
// Skeleton should be visible during loading
|
||||
const skeleton = page.locator('.skeleton, [class*="loading"]');
|
||||
// Assertion depends on skeleton implementation
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Complete Triage Workflow', () => {
|
||||
test('full workflow: view -> toggle -> select -> breadcrumb -> export', async ({ page }) => {
|
||||
// 1. Navigate to findings
|
||||
await page.goto(`${BASE_URL}/triage/findings`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 2. Verify default is Quiet lane
|
||||
const quietButton = page.locator('app-triage-lane-toggle button:has-text("Actionable")');
|
||||
await expect(quietButton).toHaveClass(/lane-toggle__btn--active/);
|
||||
|
||||
// 3. Toggle to Review lane
|
||||
const reviewButton = page.locator('app-triage-lane-toggle button:has-text("Review")');
|
||||
await reviewButton.click();
|
||||
await expect(reviewButton).toHaveClass(/lane-toggle__btn--active/);
|
||||
|
||||
// 4. Toggle back to Quiet lane
|
||||
await quietButton.click();
|
||||
await expect(quietButton).toHaveClass(/lane-toggle__btn--active/);
|
||||
|
||||
// 5. Select a finding
|
||||
const firstFinding = page.locator('.finding-card').first();
|
||||
if (await firstFinding.isVisible()) {
|
||||
await firstFinding.click();
|
||||
|
||||
// 6. Verify breadcrumb is shown
|
||||
const breadcrumb = page.locator('app-provenance-breadcrumb');
|
||||
await expect(breadcrumb).toBeVisible();
|
||||
|
||||
// 7. Verify export button is available
|
||||
const exportButton = page.locator('app-export-evidence-button');
|
||||
await expect(exportButton).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('approval workflow: select -> open drawer -> submit -> verify toast', async ({ page }) => {
|
||||
// Mock the approval API
|
||||
await page.route('**/api/v1/scans/*/approvals', route => {
|
||||
route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 'approval-123',
|
||||
createdAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${BASE_URL}/triage/findings`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Select a finding
|
||||
const firstFinding = page.locator('.finding-card').first();
|
||||
if (await firstFinding.isVisible()) {
|
||||
await firstFinding.click();
|
||||
|
||||
// Open decision drawer
|
||||
await page.locator('button:has-text("Record Decision")').click();
|
||||
await page.waitForSelector('.decision-drawer.open');
|
||||
|
||||
// Select status
|
||||
await page.keyboard.press('n'); // Not Affected
|
||||
|
||||
// Select reason
|
||||
const reasonSelect = page.locator('.reason-select');
|
||||
await reasonSelect.selectOption('vulnerable_code_not_present');
|
||||
|
||||
// Submit decision
|
||||
const submitButton = page.locator('button:has-text("Sign & Apply")');
|
||||
await submitButton.click();
|
||||
|
||||
// Verify undo toast appears
|
||||
const undoToast = page.locator('.undo-toast');
|
||||
await expect(undoToast).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user