/** * Operations UI Consolidation — E2E Tests * * Verifies Sprint 001 changes: * 1. Operations Hub, Agent Fleet, and Signals pages are removed * 2. Sidebar Ops section shows exactly 5 items * 3. Old routes redirect gracefully (no 404 / blank pages) * 4. Remaining Ops pages render correctly * 5. Topology pages (hosts, targets, agents) are unaffected * 6. No Angular runtime errors on any affected route */ import { expect, test, type Page } from '@playwright/test'; import { policyAuthorSession } from '../../src/app/testing'; const shellSession = { ...policyAuthorSession, scopes: [ ...new Set([ ...policyAuthorSession.scopes, 'ui.read', 'admin', 'ui.admin', 'orch:read', 'orch:operate', 'orch:quota', 'findings:read', 'vuln:view', 'vuln:investigate', 'vuln:operate', 'vuln:audit', 'authority:tenants.read', 'advisory:read', 'vex:read', 'exceptions:read', 'exceptions:approve', 'aoc:verify', 'policy:read', 'policy:author', 'policy:review', 'policy:approve', 'policy:simulate', 'policy:audit', 'health:read', 'notify:viewer', 'release:read', 'release:write', 'release:publish', 'sbom:read', 'signer:read', ]), ], }; const mockConfig = { authority: { issuer: 'http://127.0.0.1:4400/authority', clientId: 'stella-ops-ui', authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize', tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token', logoutEndpoint: 'http://127.0.0.1:4400/authority/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', audience: 'http://127.0.0.1:4400/gateway', dpopAlgorithms: ['ES256'], refreshLeewaySeconds: 60, }, apiBaseUrls: { authority: '/authority', scanner: '/scanner', policy: '/policy', concelier: '/concelier', attestor: '/attestor', gateway: '/gateway', }, quickstartMode: true, setup: 'complete', }; const oidcConfig = { issuer: mockConfig.authority.issuer, authorization_endpoint: mockConfig.authority.authorizeEndpoint, token_endpoint: mockConfig.authority.tokenEndpoint, jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json', response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], }; async function setupShell(page: Page): Promise { await page.addInitScript((session) => { try { window.sessionStorage.clear(); } catch { // ignore storage access errors in restricted contexts } (window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session; }, shellSession); await page.route('**/platform/envsettings.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig), }), ); await page.route('**/config.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig), }), ); await page.route('**/authority/.well-known/openid-configuration', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig), }), ); await page.route('**/.well-known/openid-configuration', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig), }), ); await page.route('**/authority/.well-known/jwks.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ keys: [] }), }), ); await page.route('**/authority/connect/**', (route) => route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'not-used-in-e2e' }), }), ); } async function go(page: Page, path: string): Promise { await page.goto(path, { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null); } async function ensureShell(page: Page): Promise { await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 30000 }); } async function assertMainHasContent(page: Page): Promise { const main = page.locator('main'); await expect(main).toHaveCount(1); await expect(main).toBeVisible(); const text = ((await main.textContent()) ?? '').replace(/\s+/g, ''); const childNodes = await main.locator('*').count(); expect(text.length > 12 || childNodes > 4).toBe(true); } function collectConsoleErrors(page: Page): string[] { const errors: string[] = []; page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); }); page.on('pageerror', (err) => errors.push(err.message)); return errors; } function collectAngularErrors(page: Page): string[] { const errors: string[] = []; page.on('console', (msg) => { const text = msg.text(); if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) { errors.push(text); } }); return errors; } test.describe.configure({ mode: 'serial' }); test.beforeEach(async ({ page }) => { await setupShell(page); }); // --------------------------------------------------------------------------- // 1. Sidebar navigation — removed items // --------------------------------------------------------------------------- test.describe('Ops nav: removed items are gone', () => { test('sidebar does NOT contain Operations Hub', async ({ page }) => { await go(page, '/ops/operations/jobs-queues'); await ensureShell(page); const sidebar = page.locator('aside.sidebar'); await expect(sidebar).not.toContainText('Operations Hub'); }); test('sidebar does NOT contain Agent Fleet', async ({ page }) => { await go(page, '/ops/operations/jobs-queues'); await ensureShell(page); const sidebar = page.locator('aside.sidebar'); await expect(sidebar).not.toContainText('Agent Fleet'); }); test('sidebar does NOT contain Signals nav item', async ({ page }) => { await go(page, '/ops/operations/jobs-queues'); await ensureShell(page); const navItems = page.locator('aside.sidebar a.nav-item'); const count = await navItems.count(); for (let i = 0; i < count; i++) { const text = (await navItems.nth(i).textContent()) ?? ''; expect(text.trim()).not.toBe('Signals'); } }); }); // --------------------------------------------------------------------------- // 2. Sidebar navigation — remaining items present // --------------------------------------------------------------------------- test.describe('Ops nav: 4 expected items are present', () => { const EXPECTED_OPS_ITEMS = [ 'Scheduled Jobs', 'Feeds & Airgap', 'Scripts', 'Diagnostics', ]; test('sidebar Ops section contains all 4 expected items', async ({ page }) => { await go(page, '/ops/operations/jobs-queues'); await ensureShell(page); const navText = (await page.locator('aside.sidebar').textContent()) ?? ''; for (const label of EXPECTED_OPS_ITEMS) { expect(navText, `Sidebar should contain "${label}"`).toContain(label); } }); for (const label of EXPECTED_OPS_ITEMS) { test(`Ops nav item "${label}" is clickable`, async ({ page }) => { await go(page, '/'); await ensureShell(page); // Expand the Operations group if collapsed const opsGroup = page.locator('aside.sidebar .sb-group__header', { hasText: 'Operations' }); if (await opsGroup.isVisible({ timeout: 3000 }).catch(() => false)) { const expanded = await opsGroup.getAttribute('aria-expanded'); if (expanded === 'false') { await opsGroup.click(); await page.waitForTimeout(500); } } const link = page.locator('aside.sidebar a.nav-item', { hasText: label }).first(); if (await link.isVisible({ timeout: 3000 }).catch(() => false)) { await link.click(); await page.waitForTimeout(1500); await ensureShell(page); await assertMainHasContent(page); } }); } }); // --------------------------------------------------------------------------- // 3. Redirects — old routes redirect gracefully // --------------------------------------------------------------------------- test.describe('Old route redirects', () => { test('/ops/operations redirects to jobs-queues (not 404)', async ({ page }) => { await go(page, '/ops/operations'); await ensureShell(page); expect(page.url()).toContain('/ops/operations/jobs-queues'); await assertMainHasContent(page); }); test('/ops/signals redirects to doctor (diagnostics)', async ({ page }) => { await go(page, '/ops/signals'); await ensureShell(page); expect(page.url()).toContain('/ops/operations/doctor'); await assertMainHasContent(page); }); test('/ops/operations landing renders non-blank page via redirect', async ({ page }) => { const errors = collectAngularErrors(page); await go(page, '/ops/operations'); await ensureShell(page); await assertMainHasContent(page); expect(errors).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // 4. Remaining Ops pages render correctly // --------------------------------------------------------------------------- test.describe('Remaining Ops pages render content', () => { test.setTimeout(120_000); const OPS_ROUTES = [ { path: '/ops/operations/jobs-queues', name: 'Scheduled Jobs' }, { path: '/ops/operations/feeds-airgap', name: 'Feeds & AirGap' }, { path: '/ops/operations/doctor', name: 'Diagnostics' }, { path: '/ops/operations/data-integrity', name: 'Data Integrity' }, { path: '/ops/operations/jobengine', name: 'JobEngine' }, { path: '/ops/operations/event-stream', name: 'Event Stream' }, ]; for (const route of OPS_ROUTES) { test(`${route.name} (${route.path}) renders with content`, async ({ page }) => { const errors = collectAngularErrors(page); await go(page, route.path); await ensureShell(page); await assertMainHasContent(page); expect( errors, `Angular errors on ${route.path}: ${errors.join('\n')}`, ).toHaveLength(0); }); } }); // --------------------------------------------------------------------------- // 5. Topology pages — unaffected by consolidation // --------------------------------------------------------------------------- test.describe('Topology pages are unaffected', () => { test.setTimeout(90_000); const TOPOLOGY_ROUTES = [ { path: '/setup/topology/overview', name: 'Topology Overview' }, { path: '/setup/topology/hosts', name: 'Topology Hosts' }, { path: '/setup/topology/targets', name: 'Topology Targets' }, { path: '/setup/topology/agents', name: 'Topology Agents' }, ]; for (const route of TOPOLOGY_ROUTES) { test(`${route.name} (${route.path}) renders correctly`, async ({ page }) => { const errors = collectAngularErrors(page); await go(page, route.path); await ensureShell(page); await assertMainHasContent(page); expect( errors, `Angular errors on ${route.path}: ${errors.join('\n')}`, ).toHaveLength(0); }); } test('agents route under operations still loads topology agents page', async ({ page }) => { const errors = collectAngularErrors(page); await go(page, '/ops/operations/agents'); await ensureShell(page); await assertMainHasContent(page); expect(errors).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // 6. Multi-route stability — no errors across the full ops journey // --------------------------------------------------------------------------- test.describe('Ops journey stability', () => { test('navigating across all ops routes produces no Angular errors', async ({ page }) => { test.setTimeout(120_000); const errors = collectAngularErrors(page); const journey = [ '/ops', '/ops/operations/jobs-queues', '/ops/operations/feeds-airgap', '/ops/operations/doctor', '/ops/operations/data-integrity', '/ops/operations/agents', '/ops/operations/event-stream', '/ops/integrations', '/ops/policy', ]; for (const route of journey) { await go(page, route); await ensureShell(page); } expect( errors, `Angular errors during ops journey: ${errors.join('\n')}`, ).toHaveLength(0); }); test('browser back/forward across ops routes works', async ({ page }) => { await go(page, '/ops/operations/jobs-queues'); await ensureShell(page); await go(page, '/ops/operations/doctor'); await ensureShell(page); await go(page, '/ops/operations/feeds-airgap'); await ensureShell(page); await page.goBack(); await page.waitForTimeout(1000); expect(page.url()).toContain('/doctor'); await page.goForward(); await page.waitForTimeout(1000); expect(page.url()).toContain('/feeds-airgap'); }); }); // --------------------------------------------------------------------------- // 7. Legacy redirect coverage // --------------------------------------------------------------------------- test.describe('Legacy platform-ops redirects', () => { test('legacy /ops/signals path redirects to diagnostics', async ({ page }) => { await go(page, '/ops/signals'); await ensureShell(page); expect(page.url()).toContain('/doctor'); }); test('/ops/operations base path redirects to jobs-queues', async ({ page }) => { await go(page, '/ops/operations'); await ensureShell(page); expect(page.url()).toContain('/jobs-queues'); }); test('/ops base path renders shell with content', async ({ page }) => { await go(page, '/ops'); await ensureShell(page); // /ops redirects to /ops/operations which redirects to /ops/operations/jobs-queues await assertMainHasContent(page); }); });