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