/** * Playwright script to capture authenticated layout screenshots of StellaOps. * * Strategy: * 1. Navigate to stella-ops.local * 2. Try the real OAuth flow first (click Sign in, fill credentials) * 3. If OAuth fails (Authority 500), inject mock auth state into Angular * to force the authenticated shell layout with sidebar * 4. Capture screenshots of various pages and states */ import { chromium } from 'playwright'; import { existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SCREENSHOT_DIR = __dirname; const BASE_URL = 'http://stella-ops.local'; const VIEWPORT_DESKTOP = { width: 1440, height: 900 }; const VIEWPORT_MOBILE = { width: 390, height: 844 }; // Mock session data to inject into Angular's AuthSessionStore const MOCK_SESSION = { tokens: { accessToken: 'mock-access-token-for-screenshot', expiresAtEpochMs: Date.now() + 3600000, // 1 hour from now refreshToken: 'mock-refresh-token', tokenType: 'Bearer', scope: 'openid profile email ui.read ui.admin authority:tenants.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read' }, identity: { subject: 'admin-user-001', name: 'Admin', email: 'admin@stella-ops.local', roles: ['admin', 'security-analyst'], }, dpopKeyThumbprint: 'mock-dpop-thumbprint-sha256', issuedAtEpochMs: Date.now(), tenantId: 'tenant-default', scopes: [ 'openid', 'profile', 'email', 'ui.read', 'ui.admin', 'authority:tenants.read', 'authority:users.read', 'authority:roles.read', 'authority:clients.read', 'authority:tokens.read', 'authority:audit.read', 'graph:read', 'graph:write', 'graph:simulate', 'graph:export', 'sbom:read', 'policy:read', 'policy:evaluate', 'policy:simulate', 'policy:author', 'policy:edit', 'policy:review', 'policy:submit', 'policy:approve', 'policy:operate', 'policy:activate', 'policy:run', 'policy:audit', 'scanner:read', 'exception:read', 'exception:write', 'release:read', 'aoc:verify', 'orch:read', 'analytics.read', 'findings:read', ], audiences: ['stella-ops-api'], authenticationTimeEpochMs: Date.now(), freshAuthActive: true, freshAuthExpiresAtEpochMs: Date.now() + 300000, }; const PERSISTED_METADATA = { subject: MOCK_SESSION.identity.subject, expiresAtEpochMs: MOCK_SESSION.tokens.expiresAtEpochMs, issuedAtEpochMs: MOCK_SESSION.issuedAtEpochMs, dpopKeyThumbprint: MOCK_SESSION.dpopKeyThumbprint, tenantId: MOCK_SESSION.tenantId, }; async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function screenshot(page, name, description) { const filePath = join(SCREENSHOT_DIR, `${name}.png`); await page.screenshot({ path: filePath, fullPage: false }); console.log(` [SCREENSHOT] ${name}.png - ${description}`); return filePath; } async function tryRealLogin(page) { console.log('\n[STEP] Attempting real OAuth login flow...'); // Navigate to homepage await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 }); await sleep(2000); // Look for Sign in button const signInButton = page.locator('button.app-auth__signin, button:has-text("Sign in")'); const hasSignIn = await signInButton.count() > 0; if (!hasSignIn) { console.log(' No Sign in button found (may already be authenticated or shell layout active)'); // Check if already in shell layout const shell = page.locator('app-shell, .shell'); if (await shell.count() > 0) { console.log(' Shell layout detected - already authenticated!'); return true; } return false; } console.log(' Sign in button found, clicking...'); await screenshot(page, '01-landing-unauthenticated', 'Landing page before sign-in'); // Click sign in - this will redirect to Authority try { // Listen for navigation to authority const [response] = await Promise.all([ page.waitForNavigation({ timeout: 10000 }).catch(() => null), signInButton.click(), ]); await sleep(2000); // Check if we're on an authority login page const currentUrl = page.url(); console.log(` Redirected to: ${currentUrl}`); if (currentUrl.includes('authority') || currentUrl.includes('authorize') || currentUrl.includes('login')) { await screenshot(page, '02-authority-login-page', 'Authority login page'); // Try to find username/password fields const usernameField = page.locator('input[name="username"], input[type="email"], input[name="login"], #username, #email'); const passwordField = page.locator('input[type="password"], input[name="password"], #password'); if (await usernameField.count() > 0 && await passwordField.count() > 0) { console.log(' Login form found, entering credentials...'); await usernameField.first().fill('admin'); await passwordField.first().fill('Admin@Stella2026!'); const submitButton = page.locator('button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign in"), button:has-text("Log in")'); if (await submitButton.count() > 0) { await submitButton.first().click(); await sleep(3000); // Check if login was successful if (page.url().includes(BASE_URL) || page.url().includes('callback')) { console.log(' Login successful!'); await page.waitForLoadState('networkidle'); await sleep(2000); return true; } } } } // If we ended up with an error if (currentUrl.includes('error') || page.url().includes('error')) { console.log(' OAuth flow resulted in error page'); await screenshot(page, '02-oauth-error', 'OAuth error page'); } } catch (error) { console.log(` OAuth flow navigation failed: ${error.message}`); } return false; } async function injectMockAuth(page) { console.log('\n[STEP] Injecting mock authentication state...'); // Navigate to base URL first await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 }); await sleep(1000); // Inject session storage and manipulate Angular internals const injected = await page.evaluate((mockData) => { const { session, persisted } = mockData; // Set session storage for persistence sessionStorage.setItem('stellaops.auth.session.info', JSON.stringify(persisted)); // Try to access Angular's dependency injection to set the session store // Angular stores component references in debug elements try { const appRoot = document.querySelector('app-root'); if (!appRoot) return { success: false, reason: 'app-root not found' }; // Access Angular's internal component instance const ngContext = appRoot['__ngContext__']; if (!ngContext) return { success: false, reason: 'no Angular context' }; return { success: true, reason: 'session storage set, need reload' }; } catch (e) { return { success: false, reason: e.message }; } }, { session: MOCK_SESSION, persisted: PERSISTED_METADATA }); console.log(` Injection result: ${JSON.stringify(injected)}`); // The most reliable approach: intercept the silent-refresh to return a successful response, // and intercept API calls to prevent errors. Then reload. // Actually, let's take a simpler approach: route intercept to mock the OIDC flow. // Set up route interception await page.route('**/connect/authorize**', async (route) => { // Redirect back to callback with a mock code const url = new URL(route.request().url()); const state = url.searchParams.get('state') || 'mock-state'; const redirectUri = url.searchParams.get('redirect_uri') || `${BASE_URL}/callback`; const callbackUrl = `${redirectUri}?code=mock-auth-code-12345&state=${state}`; await route.fulfill({ status: 302, headers: { 'Location': callbackUrl }, }); }); await page.route('**/token', async (route) => { // Return a mock token response const accessPayload = btoa(JSON.stringify({ alg: 'RS256', typ: 'at+jwt' })) .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); const accessClaims = btoa(JSON.stringify({ sub: 'admin-user-001', name: 'Admin', email: 'admin@stella-ops.local', 'stellaops:tenant': 'tenant-default', role: ['admin', 'security-analyst'], scp: MOCK_SESSION.scopes, aud: ['stella-ops-api'], auth_time: Math.floor(Date.now() / 1000), 'stellaops:fresh_auth': true, exp: Math.floor(Date.now() / 1000) + 3600, iat: Math.floor(Date.now() / 1000), iss: 'http://authority.stella-ops.local', })).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); const mockSig = 'mock-signature'; const accessToken = `${accessPayload}.${accessClaims}.${mockSig}`; const idPayload = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' })) .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); const idClaims = btoa(JSON.stringify({ sub: 'admin-user-001', name: 'Admin', email: 'admin@stella-ops.local', role: ['admin', 'security-analyst'], nonce: 'mock-nonce', exp: Math.floor(Date.now() / 1000) + 3600, iat: Math.floor(Date.now() / 1000), iss: 'http://authority.stella-ops.local', })).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); const idToken = `${idPayload}.${idClaims}.${mockSig}`; await route.fulfill({ status: 200, contentType: 'application/json', headers: { 'DPoP-Nonce': 'mock-dpop-nonce' }, body: JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600, scope: MOCK_SESSION.scopes.join(' '), refresh_token: 'mock-refresh-token', id_token: idToken, }), }); }); // Intercept console-context API calls await page.route('**/console/context**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tenants: [{ tenantId: 'tenant-default', displayName: 'Default Tenant', role: 'admin' }], selectedTenant: 'tenant-default', features: {}, }), }); }); // Intercept any API calls that would fail without auth await page.route('**/api/**', async (route) => { // Let it pass through but don't fail try { await route.continue(); } catch { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], message: 'mock response' }), }); } }); // Intercept branding await page.route('**/console/branding**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ logoUrl: null, title: 'Stella Ops', theme: 'dark', }), }); }); // Now navigate fresh and click Sign in to trigger the mocked OAuth flow await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 }); await sleep(2000); // Click Sign in const signInButton = page.locator('button.app-auth__signin, button:has-text("Sign in")'); if (await signInButton.count() > 0) { console.log(' Clicking Sign in with intercepted OAuth flow...'); // The sign in click will call beginLogin() which calls window.location.assign(authorizeUrl) // Our route intercept will redirect back to /callback with a mock code // Then completeLoginFromRedirect will exchange the code at /token (also intercepted) // But beginLogin uses window.location.assign which navigates away, // and our route intercept handles the authorize redirect. // However, the DPoP proof generation might fail... // Let's try a different approach: directly manipulate the Angular service const authInjected = await page.evaluate((sessionData) => { try { // Get Angular's injector from the app root const appRoot = document.querySelector('app-root'); if (!appRoot) return { success: false, reason: 'no app-root' }; // Use ng.getComponent and ng.getInjector for debugging const ng = (window).__ng_debug__ || (window).ng; // Try getAllAngularRootElements approach const rootElements = (window).getAllAngularRootElements?.() ?? [appRoot]; // Access __ngContext__ to find the component const ctx = appRoot.__ngContext__; if (!ctx) return { success: false, reason: 'no __ngContext__' }; // Walk the injector tree to find AuthSessionStore // In Angular 19+, the LView is the context array // The injector is typically at index 9 or via the directive flags // Alternative approach: use the debugging API if available if (typeof (window).ng !== 'undefined') { const component = (window).ng.getComponent(appRoot); if (component) { // Access sessionStore through the component's injected dependencies const sessionStore = component.sessionStore; if (sessionStore && typeof sessionStore.setSession === 'function') { sessionStore.setSession(sessionData); return { success: true, method: 'ng.getComponent -> sessionStore.setSession' }; } // Try via the auth property path if (component.auth && component.sessionStore) { component.sessionStore.setSession(sessionData); return { success: true, method: 'component.sessionStore' }; } } } return { success: false, reason: 'could not access Angular internals' }; } catch (e) { return { success: false, reason: e.message }; } }, MOCK_SESSION); console.log(` Direct Angular injection result: ${JSON.stringify(authInjected)}`); if (!authInjected.success) { // Last resort: navigate with mocked OAuth - click Sign in and let interceptors handle it // The DPoP createProof will be called which uses WebCrypto - should work in Playwright console.log(' Attempting OAuth flow with route interception...'); try { await Promise.all([ page.waitForURL('**/callback**', { timeout: 10000 }).catch(() => null), signInButton.click(), ]); await sleep(3000); console.log(` After sign-in click, URL: ${page.url()}`); // Wait for the app to process the callback await page.waitForLoadState('networkidle').catch(() => {}); await sleep(2000); } catch (e) { console.log(` OAuth interception flow error: ${e.message}`); } } } // Check if we're authenticated now const isAuthenticated = await page.evaluate(() => { const appRoot = document.querySelector('app-root'); const shell = document.querySelector('app-shell, .shell'); const sidebar = document.querySelector('app-sidebar, .sidebar'); return { hasShell: !!shell, hasSidebar: !!sidebar, hasAppRoot: !!appRoot, url: window.location.href, sessionStorage: sessionStorage.getItem('stellaops.auth.session.info'), }; }); console.log(` Auth check: ${JSON.stringify(isAuthenticated)}`); return isAuthenticated.hasShell || isAuthenticated.hasSidebar; } async function captureAuthenticatedScreenshots(page) { console.log('\n[STEP] Capturing authenticated layout screenshots...'); await sleep(2000); // 1. Main dashboard with sidebar await screenshot(page, '03-dashboard-sidebar-expanded', 'Dashboard with sidebar visible (1440x900)'); // 2. Wait for sidebar to be visible and capture it expanded const sidebar = page.locator('app-sidebar, .sidebar'); if (await sidebar.count() > 0) { console.log(' Sidebar component found'); // Try to expand nav groups const navGroups = page.locator('.sidebar__group-header, .nav-group__header, [class*="group-toggle"], [class*="nav-group"]'); const groupCount = await navGroups.count(); console.log(` Found ${groupCount} nav group elements`); // Click to expand some groups for (let i = 0; i < Math.min(groupCount, 5); i++) { try { await navGroups.nth(i).click(); await sleep(300); } catch (e) { // Some may not be clickable } } await sleep(500); await screenshot(page, '04-sidebar-groups-expanded', 'Sidebar with navigation groups expanded'); } // 3. Navigate to different pages const pages = [ { route: '/findings', name: '05-findings-page', desc: 'Scans & Findings page' }, { route: '/vulnerabilities', name: '06-vulnerabilities-page', desc: 'Vulnerabilities page' }, { route: '/triage/artifacts', name: '07-triage-page', desc: 'Triage page' }, { route: '/policy-studio/packs', name: '08-policy-studio', desc: 'Policy Studio page' }, { route: '/evidence', name: '09-evidence-page', desc: 'Evidence page' }, { route: '/ops/health', name: '10-operations-health', desc: 'Operations health page' }, { route: '/settings', name: '11-settings-page', desc: 'Settings page' }, { route: '/console/admin/tenants', name: '12-admin-tenants', desc: 'Admin tenants page' }, ]; for (const pg of pages) { try { await page.goto(`${BASE_URL}${pg.route}`, { waitUntil: 'domcontentloaded', timeout: 10000 }); await sleep(1500); await page.waitForLoadState('networkidle').catch(() => {}); await screenshot(page, pg.name, pg.desc); } catch (e) { console.log(` Failed to capture ${pg.name}: ${e.message}`); } } // 4. Collapsed sidebar console.log('\n Toggling sidebar to collapsed state...'); const collapseToggle = page.locator('[class*="collapse-toggle"], [class*="sidebar-toggle"], button[aria-label*="collapse"], button[aria-label*="toggle"]'); if (await collapseToggle.count() > 0) { await collapseToggle.first().click(); await sleep(500); await screenshot(page, '13-sidebar-collapsed', 'Sidebar in collapsed state'); } else { // Try the sidebar component's collapse button const sidebarButtons = page.locator('app-sidebar button, .sidebar button'); const btnCount = await sidebarButtons.count(); for (let i = 0; i < btnCount; i++) { const text = await sidebarButtons.nth(i).textContent().catch(() => ''); const ariaLabel = await sidebarButtons.nth(i).getAttribute('aria-label').catch(() => ''); if (text?.includes('collapse') || text?.includes('Collapse') || ariaLabel?.includes('collapse') || ariaLabel?.includes('Collapse') || text?.includes('<<') || text?.includes('toggle')) { await sidebarButtons.nth(i).click(); await sleep(500); break; } } await screenshot(page, '13-sidebar-collapsed', 'Sidebar collapsed (or toggle not found)'); } // 5. Mobile viewport console.log('\n Switching to mobile viewport...'); await page.setViewportSize(VIEWPORT_MOBILE); await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 10000 }); await sleep(2000); await screenshot(page, '14-mobile-viewport', 'Mobile viewport (390px)'); // Try to open mobile menu const menuToggle = page.locator('[class*="menu-toggle"], [class*="hamburger"], button[aria-label*="menu"], button[aria-label*="Menu"]'); if (await menuToggle.count() > 0) { await menuToggle.first().click(); await sleep(500); await screenshot(page, '15-mobile-menu-open', 'Mobile viewport with menu open'); } } async function captureUnauthenticatedScreenshots(page) { console.log('\n[STEP] Capturing what is visible on the live site...'); await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 }); await sleep(3000); // Check what's actually rendered const pageState = await page.evaluate(() => { const elements = { appRoot: !!document.querySelector('app-root'), appShell: !!document.querySelector('app-shell'), sidebar: !!document.querySelector('app-sidebar'), topbar: !!document.querySelector('app-topbar'), header: !!document.querySelector('.app-header'), signIn: !!document.querySelector('.app-auth__signin'), splash: !!document.querySelector('#stella-splash'), shell: !!document.querySelector('.shell'), navigation: !!document.querySelector('app-navigation-menu'), breadcrumb: !!document.querySelector('app-breadcrumb'), }; return elements; }); console.log(` Page element state: ${JSON.stringify(pageState, null, 2)}`); await screenshot(page, '01-landing-page', 'Landing page state'); } async function main() { console.log('=== StellaOps Authenticated Layout Screenshot Capture ===\n'); console.log(`Target: ${BASE_URL}`); console.log(`Output: ${SCREENSHOT_DIR}\n`); const browser = await chromium.launch({ headless: true, args: ['--ignore-certificate-errors', '--allow-insecure-localhost'], }); const context = await browser.newContext({ viewport: VIEWPORT_DESKTOP, ignoreHTTPSErrors: true, bypassCSP: true, }); const page = await context.newPage(); // Enable console log forwarding page.on('console', msg => { if (msg.type() === 'error') { console.log(` [BROWSER ERROR] ${msg.text()}`); } }); try { // Step 1: Capture current state await captureUnauthenticatedScreenshots(page); // Step 2: Try real login const realLoginSuccess = await tryRealLogin(page); if (realLoginSuccess) { console.log('\n Real login succeeded!'); await captureAuthenticatedScreenshots(page); } else { console.log('\n Real login failed. Attempting mock auth injection...'); // Close and create new context for clean state await page.close(); const newPage = await context.newPage(); page.on('console', msg => { if (msg.type() === 'error') { console.log(` [BROWSER ERROR] ${msg.text()}`); } }); const mockSuccess = await injectMockAuth(newPage); if (mockSuccess) { console.log('\n Mock auth injection succeeded!'); await captureAuthenticatedScreenshots(newPage); } else { console.log('\n Mock auth injection did not produce shell layout.'); console.log(' Capturing available state with additional details...'); // Capture whatever we can see await newPage.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 }); await sleep(3000); // Get detailed DOM info const domInfo = await newPage.evaluate(() => { const getTextContent = (selector) => { const el = document.querySelector(selector); return el ? el.textContent?.trim()?.substring(0, 200) : null; }; const getInnerHTML = (selector) => { const el = document.querySelector(selector); return el ? el.innerHTML?.substring(0, 500) : null; }; return { title: document.title, bodyClasses: document.body.className, appRootHTML: getInnerHTML('app-root'), mainContent: getTextContent('.app-content') || getTextContent('main'), allComponents: Array.from(document.querySelectorAll('*')) .filter(el => el.tagName.includes('-')) .map(el => el.tagName.toLowerCase()) .filter((v, i, a) => a.indexOf(v) === i) .slice(0, 30), }; }); console.log('\n DOM Info:'); console.log(` Title: ${domInfo.title}`); console.log(` Body classes: ${domInfo.bodyClasses}`); console.log(` Custom elements: ${domInfo.allComponents.join(', ')}`); if (domInfo.appRootHTML) { console.log(` app-root HTML (first 500 chars): ${domInfo.appRootHTML}`); } await screenshot(newPage, '99-final-state', 'Final page state after all attempts'); await newPage.close(); } } } catch (error) { console.error(`\nFatal error: ${error.message}`); console.error(error.stack); try { await screenshot(page, '99-error-state', 'Error state'); } catch {} } finally { await browser.close(); } console.log('\n=== Screenshot capture complete ==='); } main().catch(console.error);