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-shell-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; } test.describe.configure({ mode: 'serial' }); test.beforeEach(async ({ page }) => { await setupShell(page); }); test.describe('Nav shell canonical domains', () => { test('sidebar renders all canonical root labels', async ({ page }) => { await go(page, '/mission-control/board'); await ensureShell(page); const navText = (await page.locator('aside.sidebar').textContent()) ?? ''; const labels = [ 'Release Control', 'Security & Evidence', 'Platform & Setup', 'Dashboard', 'Releases', 'Vulnerabilities', 'Evidence', 'Operations', 'Setup', ]; for (const label of labels) { expect(navText).toContain(label); } }); test('sidebar excludes deprecated root labels', async ({ page }) => { await go(page, '/mission-control/board'); await ensureShell(page); const navText = (await page.locator('aside.sidebar').textContent()) ?? ''; expect(navText).not.toContain('Security & Risk'); expect(navText).not.toContain('Evidence & Audit'); expect(navText).not.toContain('Platform Ops'); expect(navText).not.toContain('Administration'); expect(navText).not.toContain('Policy Studio'); }); test('group headers are unique and navigate to group landing routes', async ({ page }) => { await go(page, '/mission-control/board'); await ensureShell(page); const releaseGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Release Control' }); const securityGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Security & Evidence' }); const platformGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Platform & Setup' }); await expect(releaseGroup).toHaveCount(1); await expect(securityGroup).toHaveCount(1); await expect(platformGroup).toHaveCount(1); await releaseGroup.click(); await expect(page).toHaveURL(/\/mission-control\/board$/); await securityGroup.click(); await expect(page).toHaveURL(/\/security(\/|$)/); await platformGroup.click(); await expect(page).toHaveURL(/\/ops(\/|$)/); }); test('grouped root entries navigate when clicked', async ({ page }) => { await go(page, '/mission-control/board'); await ensureShell(page); await page.locator('aside.sidebar a.nav-item', { hasText: 'Releases' }).first().click(); await expect(page).toHaveURL(/\/releases\/deployments$/); await page.locator('aside.sidebar a.nav-item', { hasText: 'Vulnerabilities' }).first().click(); await expect(page).toHaveURL(/\/security(\/|$)/); await page.locator('aside.sidebar a.nav-item', { hasText: 'Evidence' }).first().click(); await expect(page).toHaveURL(/\/evidence\/overview$/); await page.locator('aside.sidebar a.nav-item', { hasText: 'Operations' }).first().click(); await expect(page).toHaveURL(/\/ops\/operations$/); }); }); test.describe('Nav shell breadcrumbs and stability', () => { const breadcrumbRoutes: Array<{ path: string; expected: string }> = [ { path: '/mission-control/board', expected: 'Mission Board' }, { path: '/releases/versions', expected: 'Release Versions' }, { path: '/security/triage', expected: 'Triage' }, { path: '/evidence/verify-replay', expected: 'Verify & Replay' }, { path: '/ops/operations/data-integrity', expected: 'Data Integrity' }, { path: '/setup/topology/agents', expected: 'Agent Fleet' }, ]; for (const route of breadcrumbRoutes) { test(`breadcrumb renders on ${route.path}`, async ({ page }) => { await go(page, route.path); await ensureShell(page); const breadcrumb = page.locator('app-breadcrumb nav.breadcrumb'); await expect(breadcrumb).toHaveCount(1); await expect(breadcrumb).toContainText(route.expected); }); } test('canonical roots produce no app runtime errors', async ({ page }) => { const errors = collectConsoleErrors(page); const routes = ['/mission-control/board', '/releases', '/security', '/evidence', '/ops', '/setup']; for (const route of routes) { await go(page, route); await ensureShell(page); } const appErrors = errors.filter( (error) => !error.includes('ERR_FAILED') && !error.includes('ERR_BLOCKED') && !error.includes('ERR_CONNECTION_REFUSED') && !error.includes('404') && error.length > 0, ); expect(appErrors).toEqual([]); }); }); test.describe('Pack route render checks', () => { test('release routes render non-blank content', async ({ page }) => { test.setTimeout(60_000); const routes = [ '/releases/overview', '/releases/versions', '/releases/runs', '/releases/approvals', '/releases/hotfixes', '/releases/promotion-queue', '/releases/environments', '/releases/deployments', '/releases/versions/new', ]; for (const route of routes) { await go(page, route); await ensureShell(page); expect(new URL(page.url()).pathname).toBe(route); await assertMainHasContent(page); } }); test('security and evidence routes render non-blank content', async ({ page }) => { test.setTimeout(60_000); const routes = [ '/security/posture', '/security/triage', '/security/advisories-vex', '/security/supply-chain-data', '/security/reachability', '/security/reports', '/evidence/overview', '/evidence/capsules', '/evidence/verify-replay', '/evidence/exports', '/evidence/audit-log', ]; for (const route of routes) { await go(page, route); await ensureShell(page); expect(new URL(page.url()).pathname).toBe(route); await assertMainHasContent(page); } }); test('ops and setup routes render non-blank content', async ({ page }) => { test.setTimeout(180_000); const routes = [ '/ops', '/ops/operations', '/ops/operations/data-integrity', '/ops/operations/jobengine', '/ops/integrations', '/ops/integrations/advisory-vex-sources', '/ops/policy', '/ops/platform-setup', '/setup', '/setup/topology/overview', '/setup/topology/targets', '/setup/topology/hosts', '/setup/topology/agents', ]; for (const route of routes) { await go(page, route); await ensureShell(page); expect(new URL(page.url()).pathname).toBe(route); await assertMainHasContent(page); } }); }); test.describe('Nav shell responsive layout', () => { test('desktop viewport shows sidebar', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await go(page, '/mission-control/board'); await expect(page.locator('aside.sidebar')).toBeVisible(); }); test('mobile viewport remains usable without horizontal overflow', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await go(page, '/mission-control/board'); await expect(page.locator('.topbar__menu-toggle')).toBeVisible(); const hasHorizontalScroll = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, ); expect(hasHorizontalScroll).toBe(false); }); });