diff --git a/src/Web/StellaOps.Web/e2e/workflows/navigation-restructure.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/workflows/navigation-restructure.e2e.spec.ts new file mode 100644 index 000000000..c3387d28a --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/workflows/navigation-restructure.e2e.spec.ts @@ -0,0 +1,291 @@ +/** + * E2E Test Suite — Navigation IA Restructure + * + * Covers sidebar structure, tab behavior, redirects, approve/reject actions, + * and cross-page navigation after the IA restructure. + * + * Runs against the live Docker stack at https://stella-ops.local. + */ + +import { test, expect } from '../fixtures/auth.fixture'; +import { navigateAndWait, assertPageHasContent } from '../helpers/nav.helper'; +import type { Page } from '@playwright/test'; + +async function go(page: Page, path: string) { + await navigateAndWait(page, path, { timeout: 30_000 }); +} + +function setupErrorCollector(page: Page): string[] { + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error' && /NG0\d{3,4}/.test(msg.text())) { + errors.push(msg.text()); + } + }); + return errors; +} + +// =========================================================================== +// GROUP 1 — Sidebar Navigation Structure +// =========================================================================== +test.describe('Sidebar: 7-group structure', () => { + test('Dashboard link is ungrouped and first', async ({ authenticatedPage: page }) => { + await go(page, '/'); + const dashboard = page.locator('nav').getByRole('link', { name: 'Dashboard' }); + await expect(dashboard).toBeVisible({ timeout: 10_000 }); + }); + + test('Release Control group has Deployments, Releases, Environments', async ({ authenticatedPage: page }) => { + await go(page, '/'); + const group = page.getByRole('group', { name: 'Release Control' }); + await expect(group).toBeVisible({ timeout: 10_000 }); + await expect(group.getByRole('link', { name: 'Deployments' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'Releases' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'Environments' })).toBeVisible(); + }); + + test('Policy group has Packs, Governance, Simulation, VEX, Audit', async ({ authenticatedPage: page }) => { + await go(page, '/'); + const group = page.getByRole('group', { name: 'Policy' }); + await expect(group).toBeVisible({ timeout: 10_000 }); + await expect(group.getByRole('link', { name: 'Packs' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'Governance' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'Simulation' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'VEX & Exceptions' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'Policy Audit' })).toBeVisible(); + }); + + test('Policy group does NOT have Release Gates', async ({ authenticatedPage: page }) => { + await go(page, '/'); + const group = page.getByRole('group', { name: 'Policy' }); + await expect(group).toBeVisible({ timeout: 10_000 }); + await expect(group.getByRole('link', { name: 'Release Gates' })).not.toBeVisible(); + }); + + test('Security group does NOT have Reports', async ({ authenticatedPage: page }) => { + await go(page, '/'); + const group = page.getByRole('group', { name: 'Security' }); + await expect(group).toBeVisible({ timeout: 10_000 }); + await expect(group.getByRole('link', { name: 'Reports' })).not.toBeVisible(); + }); + + test('Operations group has 8 items (no Trust Analytics, no Policy)', async ({ authenticatedPage: page }) => { + await go(page, '/'); + const group = page.getByRole('group', { name: 'Operations' }); + await expect(group).toBeVisible({ timeout: 10_000 }); + await expect(group.getByRole('link', { name: 'Operations Hub' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'Scheduled Jobs' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'Diagnostics' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'Feeds & Airgap' })).toBeVisible(); + // Should NOT have Policy (moved to own group) or Trust Analytics (merged) + await expect(group.getByRole('link', { name: 'Policy' })).not.toBeVisible(); + await expect(group.getByRole('link', { name: 'Trust Analytics' })).not.toBeVisible(); + }); + + test('Audit & Evidence has Trust (not Trust Audit)', async ({ authenticatedPage: page }) => { + await go(page, '/'); + const group = page.getByRole('group', { name: 'Audit & Evidence' }); + await expect(group).toBeVisible({ timeout: 10_000 }); + await expect(group.getByRole('link', { name: 'Trust' })).toBeVisible(); + await expect(group.getByRole('link', { name: 'Trust Audit' })).not.toBeVisible(); + }); +}); + +// =========================================================================== +// GROUP 2 — Releases Page (unified tabs) +// =========================================================================== +test.describe('Releases Page: unified Releases + Versions tabs', () => { + test('Page title is Release Control', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await go(page, '/releases'); + const heading = page.locator('h1').first(); + await expect(heading).toBeVisible({ timeout: 10_000 }); + await expect(heading).toContainText('Release Control'); + expect(ngErrors).toHaveLength(0); + }); + + test('Has Releases and Versions tabs', async ({ authenticatedPage: page }) => { + await go(page, '/releases'); + await expect(page.getByRole('tab', { name: 'Releases' })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('tab', { name: 'Versions' })).toBeVisible(); + }); + + test('Default tab is Releases', async ({ authenticatedPage: page }) => { + await go(page, '/releases'); + const releasesTab = page.getByRole('tab', { name: 'Releases' }); + await expect(releasesTab).toBeVisible({ timeout: 10_000 }); + await expect(releasesTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('Switching to Versions tab changes button to New Version', async ({ authenticatedPage: page }) => { + await go(page, '/releases'); + await page.getByRole('tab', { name: 'Versions' }).click(); + await page.waitForTimeout(500); + // Page action should change + const newVersionLink = page.getByRole('link', { name: /New Version/i }); + await expect(newVersionLink).toBeVisible({ timeout: 5_000 }); + }); + + test('/releases/versions redirects to /releases?tab=versions', async ({ authenticatedPage: page }) => { + await go(page, '/releases/versions'); + await page.waitForTimeout(1000); + expect(page.url()).toContain('tab=versions'); + }); +}); + +// =========================================================================== +// GROUP 3 — Deployments Page (Pipeline + Approvals) +// =========================================================================== +test.describe('Deployments Page: Pipeline + Approvals', () => { + test('Has Pipeline and Approvals tabs', async ({ authenticatedPage: page }) => { + await go(page, '/releases/deployments'); + await expect(page.getByRole('tab', { name: 'Pipeline' })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('tab', { name: 'Approvals' })).toBeVisible(); + }); + + test('Does NOT have Table or Correlations tabs', async ({ authenticatedPage: page }) => { + await go(page, '/releases/deployments'); + await page.waitForTimeout(1000); + await expect(page.getByRole('tab', { name: 'Table' })).not.toBeVisible(); + await expect(page.getByRole('tab', { name: 'Correlations' })).not.toBeVisible(); + }); + + test('Does NOT have redundant context chips', async ({ authenticatedPage: page }) => { + await go(page, '/releases/deployments'); + await page.waitForTimeout(1000); + // The old .context div with region/env/time chips should be gone + const contextDiv = page.locator('.context'); + await expect(contextDiv).not.toBeVisible(); + }); + + test('Approvals tab has gate summary cards', async ({ authenticatedPage: page }) => { + await go(page, '/releases/deployments?view=approvals'); + await page.waitForTimeout(1000); + const summary = page.locator('.gate-summary'); + await expect(summary).toBeVisible({ timeout: 10_000 }); + }); + + test('Approvals tab has Gate Type column', async ({ authenticatedPage: page }) => { + await go(page, '/releases/deployments?view=approvals'); + await page.waitForTimeout(1000); + await expect(page.locator('th', { hasText: 'Gate Type' })).toBeVisible({ timeout: 10_000 }); + }); + + test('Approvals tab has search input', async ({ authenticatedPage: page }) => { + await go(page, '/releases/deployments?view=approvals'); + await page.waitForTimeout(1000); + const search = page.locator('input[placeholder*="Search approvals"]'); + await expect(search).toBeVisible({ timeout: 10_000 }); + }); + + test('No Angular errors on Deployments page', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await go(page, '/releases/deployments'); + await page.waitForTimeout(2000); + expect(ngErrors).toHaveLength(0); + }); +}); + +// =========================================================================== +// GROUP 4 — Route Redirects +// =========================================================================== +test.describe('Route redirects preserve bookmarks', () => { + test('/security/reports redirects to /security', async ({ authenticatedPage: page }) => { + await go(page, '/security/reports'); + await page.waitForTimeout(1000); + expect(page.url()).toContain('/security'); + expect(page.url()).not.toContain('/reports'); + }); + + test('/triage/audit-bundles redirects to /evidence/bundles', async ({ authenticatedPage: page }) => { + await go(page, '/triage/audit-bundles'); + await page.waitForTimeout(1000); + expect(page.url()).toContain('/evidence/bundles'); + }); +}); + +// =========================================================================== +// GROUP 5 — Page Content (no duplicate headings, no eyebrows) +// =========================================================================== +test.describe('Page content: no duplicate headings or eyebrows', () => { + test('Dashboard heading matches breadcrumb', async ({ authenticatedPage: page }) => { + await go(page, '/'); + const heading = page.locator('h1').first(); + await expect(heading).toBeVisible({ timeout: 10_000 }); + await expect(heading).toContainText('Dashboard'); + }); + + test('Vulnerabilities heading (not Artifact workspace)', async ({ authenticatedPage: page }) => { + await go(page, '/triage/artifacts'); + const heading = page.locator('h1').first(); + await expect(heading).toBeVisible({ timeout: 10_000 }); + await expect(heading).toContainText('Vulnerabilities'); + }); + + test('Scheduled Jobs heading (not JobEngine)', async ({ authenticatedPage: page }) => { + await go(page, '/ops/operations/jobengine'); + const heading = page.locator('h1').first(); + await expect(heading).toBeVisible({ timeout: 10_000 }); + await expect(heading).toContainText('Scheduled Jobs'); + }); + + test('Notifications heading (not Notification Operations)', async ({ authenticatedPage: page }) => { + await go(page, '/ops/operations/notifications'); + const heading = page.locator('h1').first(); + await expect(heading).toBeVisible({ timeout: 10_000 }); + await expect(heading).toContainText('Notifications'); + await expect(heading).not.toContainText('Notification Operations'); + }); + + test('Audit Log heading (not Unified Audit Log)', async ({ authenticatedPage: page }) => { + await go(page, '/evidence/audit-log'); + const heading = page.locator('h1').first(); + await expect(heading).toBeVisible({ timeout: 10_000 }); + await expect(heading).toContainText('Audit Log'); + await expect(heading).not.toContainText('Unified'); + }); + + test('No Angular NG02100 errors on Audit Log', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await go(page, '/evidence/audit-log'); + await page.waitForTimeout(2000); + expect(ngErrors).toHaveLength(0); + }); +}); + +// =========================================================================== +// GROUP 6 — Doctor/Diagnostics +// =========================================================================== +test.describe('Diagnostics page', () => { + test('Check labels say NOT RUN (not NOT RAN)', async ({ authenticatedPage: page }) => { + await go(page, '/ops/operations/doctor'); + await page.waitForTimeout(2000); + const notRunLabels = page.locator('text=NOT RUN'); + const notRanLabels = page.locator('text=NOT RAN'); + expect(await notRanLabels.count()).toBe(0); + expect(await notRunLabels.count()).toBeGreaterThan(0); + }); +}); + +// =========================================================================== +// GROUP 7 — Breadcrumb consistency +// =========================================================================== +test.describe('Breadcrumb consistency', () => { + test('Operations breadcrumb not duplicated', async ({ authenticatedPage: page }) => { + await go(page, '/ops/operations/signals'); + const breadcrumbs = page.locator('nav[aria-label="Breadcrumb"] li'); + await expect(breadcrumbs).toHaveCount(2, { timeout: 10_000 }); // "Operations" > "Signals" + const texts = await breadcrumbs.allTextContents(); + // Should NOT have "Operations" twice + const opsCount = texts.filter(t => t.trim() === 'Operations').length; + expect(opsCount).toBeLessThanOrEqual(1); + }); + + test('Ops breadcrumb says Operations (not Ops)', async ({ authenticatedPage: page }) => { + await go(page, '/ops/operations'); + const breadcrumb = page.locator('nav[aria-label="Breadcrumb"]'); + await expect(breadcrumb).toBeVisible({ timeout: 10_000 }); + await expect(breadcrumb).toContainText('Operations'); + await expect(breadcrumb).not.toContainText(/\bOps\b/); + }); +});