frontend styling fixes
This commit is contained in:
321
src/Web/StellaOps.Web/screenshots/auth/capture-remaining.mjs
Normal file
321
src/Web/StellaOps.Web/screenshots/auth/capture-remaining.mjs
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Capture remaining screenshots: collapsed sidebar and mobile viewport.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
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';
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Capture Remaining Screenshots ===\n');
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--ignore-certificate-errors'],
|
||||
});
|
||||
|
||||
try {
|
||||
// ===== Part 1: Collapsed sidebar =====
|
||||
console.log('[PART 1] Capturing collapsed sidebar...');
|
||||
const desktopContext = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
ignoreHTTPSErrors: true,
|
||||
bypassCSP: true,
|
||||
});
|
||||
const desktopPage = await desktopContext.newPage();
|
||||
desktopPage.on('console', msg => {
|
||||
if (msg.type() === 'error' && !msg.text().includes('404') && !msg.text().includes('401') && !msg.text().includes('500')) {
|
||||
console.log(` [BROWSER] ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await desktopPage.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await sleep(3000);
|
||||
|
||||
// Describe what we see
|
||||
const desktopState = await desktopPage.evaluate(() => {
|
||||
const shell = document.querySelector('.shell');
|
||||
const sidebar = document.querySelector('app-sidebar');
|
||||
const topbar = document.querySelector('app-topbar');
|
||||
|
||||
// Find all buttons in sidebar
|
||||
const sidebarButtons = sidebar ? Array.from(sidebar.querySelectorAll('button')).map(btn => ({
|
||||
text: btn.textContent?.trim()?.substring(0, 50),
|
||||
ariaLabel: btn.getAttribute('aria-label'),
|
||||
className: btn.className,
|
||||
tagName: btn.tagName,
|
||||
})) : [];
|
||||
|
||||
// Find nav items
|
||||
const navItems = sidebar ? Array.from(sidebar.querySelectorAll('a, [routerLink]')).map(a => ({
|
||||
text: a.textContent?.trim()?.substring(0, 50),
|
||||
href: a.getAttribute('href') || a.getAttribute('routerLink'),
|
||||
})).slice(0, 30) : [];
|
||||
|
||||
// Shell classes
|
||||
const shellClasses = shell?.className || '';
|
||||
|
||||
return {
|
||||
shellClasses,
|
||||
hasSidebar: !!sidebar,
|
||||
hasTopbar: !!topbar,
|
||||
sidebarButtons,
|
||||
navItems,
|
||||
sidebarHTML: sidebar?.innerHTML?.substring(0, 2000),
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` Shell classes: ${desktopState.shellClasses}`);
|
||||
console.log(` Has sidebar: ${desktopState.hasSidebar}`);
|
||||
console.log(` Has topbar: ${desktopState.hasTopbar}`);
|
||||
console.log(` Sidebar buttons (${desktopState.sidebarButtons.length}):`);
|
||||
for (const btn of desktopState.sidebarButtons) {
|
||||
console.log(` - "${btn.text}" [class="${btn.className}"] [aria-label="${btn.ariaLabel}"]`);
|
||||
}
|
||||
console.log(` Nav items (${desktopState.navItems.length}):`);
|
||||
for (const item of desktopState.navItems.slice(0, 15)) {
|
||||
console.log(` - "${item.text}" -> ${item.href}`);
|
||||
}
|
||||
|
||||
// First screenshot: expanded sidebar (clean)
|
||||
await screenshot(desktopPage, '03a-dashboard-full', 'Dashboard with full sidebar expanded');
|
||||
|
||||
// Navigate to different pages with sidebar active states
|
||||
await desktopPage.goto(`${BASE_URL}/findings`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await sleep(2000);
|
||||
await screenshot(desktopPage, '05a-findings-active', 'Findings page with sidebar active state');
|
||||
|
||||
// Try to find and click the collapse toggle
|
||||
// Look for the toggle button in the sidebar
|
||||
const collapseResult = await desktopPage.evaluate(() => {
|
||||
const sidebar = document.querySelector('app-sidebar');
|
||||
if (!sidebar) return { found: false, reason: 'no sidebar' };
|
||||
|
||||
// Look for collapse toggle - common patterns
|
||||
const buttons = Array.from(sidebar.querySelectorAll('button'));
|
||||
for (const btn of buttons) {
|
||||
const label = btn.getAttribute('aria-label') || '';
|
||||
const text = btn.textContent?.trim() || '';
|
||||
const cls = btn.className || '';
|
||||
if (label.toLowerCase().includes('collapse') ||
|
||||
label.toLowerCase().includes('toggle') ||
|
||||
text.toLowerCase().includes('collapse') ||
|
||||
cls.includes('collapse') ||
|
||||
cls.includes('toggle') ||
|
||||
// Chevron/arrow icons
|
||||
btn.querySelector('[class*="chevron"]') ||
|
||||
btn.querySelector('[class*="arrow"]')) {
|
||||
btn.click();
|
||||
return { found: true, label, text: text.substring(0, 30), className: cls };
|
||||
}
|
||||
}
|
||||
|
||||
// Also look for a direct class or CSS manipulation approach
|
||||
// The AppShellComponent has sidebarCollapsed signal
|
||||
const shellElement = document.querySelector('.shell');
|
||||
if (shellElement) {
|
||||
// Toggle the collapsed class directly
|
||||
shellElement.classList.toggle('shell--sidebar-collapsed');
|
||||
return { found: true, method: 'class-toggle' };
|
||||
}
|
||||
|
||||
return { found: false, reason: 'no toggle found' };
|
||||
});
|
||||
|
||||
console.log(` Collapse toggle result: ${JSON.stringify(collapseResult)}`);
|
||||
await sleep(500);
|
||||
await screenshot(desktopPage, '13-sidebar-collapsed', 'Sidebar collapsed state');
|
||||
|
||||
// Expand it back for comparison
|
||||
await desktopPage.evaluate(() => {
|
||||
const shell = document.querySelector('.shell');
|
||||
if (shell && shell.classList.contains('shell--sidebar-collapsed')) {
|
||||
shell.classList.remove('shell--sidebar-collapsed');
|
||||
}
|
||||
});
|
||||
await sleep(300);
|
||||
|
||||
// Navigate to more pages to show active states
|
||||
const navPages = [
|
||||
{ url: '/reachability', name: '05b-reachability', desc: 'Reachability page with Security group active' },
|
||||
{ url: '/exceptions', name: '07a-exceptions', desc: 'Exceptions page with Triage group active' },
|
||||
{ url: '/evidence', name: '09a-evidence-active', desc: 'Evidence page with Evidence group active' },
|
||||
{ url: '/ops/health', name: '10a-ops-health-active', desc: 'Health page with Operations group active' },
|
||||
{ url: '/notify', name: '10b-notifications', desc: 'Notifications page' },
|
||||
];
|
||||
|
||||
for (const pg of navPages) {
|
||||
try {
|
||||
await desktopPage.goto(`${BASE_URL}${pg.url}`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await sleep(1500);
|
||||
await screenshot(desktopPage, pg.name, pg.desc);
|
||||
} catch (e) {
|
||||
console.log(` Failed: ${pg.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await desktopContext.close();
|
||||
|
||||
// ===== Part 2: Mobile viewport =====
|
||||
console.log('\n[PART 2] Capturing mobile viewport...');
|
||||
const mobileContext = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
ignoreHTTPSErrors: true,
|
||||
bypassCSP: true,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
});
|
||||
const mobilePage = await mobileContext.newPage();
|
||||
mobilePage.on('console', msg => {
|
||||
if (msg.type() === 'error' && !msg.text().includes('404') && !msg.text().includes('401') && !msg.text().includes('500')) {
|
||||
console.log(` [BROWSER] ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await mobilePage.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await sleep(3000);
|
||||
|
||||
// Check mobile layout
|
||||
const mobileState = await mobilePage.evaluate(() => {
|
||||
const shell = document.querySelector('.shell');
|
||||
const sidebar = document.querySelector('app-sidebar');
|
||||
const topbar = document.querySelector('app-topbar');
|
||||
const menuButton = topbar?.querySelector('button');
|
||||
|
||||
return {
|
||||
shellClasses: shell?.className,
|
||||
sidebarTransform: sidebar ? window.getComputedStyle(sidebar).transform : null,
|
||||
sidebarDisplay: sidebar ? window.getComputedStyle(sidebar).display : null,
|
||||
sidebarVisibility: sidebar ? window.getComputedStyle(sidebar).visibility : null,
|
||||
hasTopbar: !!topbar,
|
||||
topbarButtons: topbar ? Array.from(topbar.querySelectorAll('button')).map(btn => ({
|
||||
text: btn.textContent?.trim()?.substring(0, 50),
|
||||
ariaLabel: btn.getAttribute('aria-label'),
|
||||
className: btn.className,
|
||||
})) : [],
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` Mobile state: ${JSON.stringify(mobileState, null, 2)}`);
|
||||
|
||||
await screenshot(mobilePage, '14-mobile-dashboard', 'Mobile viewport - dashboard (390px)');
|
||||
|
||||
// Try to find the mobile menu toggle button in the topbar
|
||||
const topbarMenuBtn = mobilePage.locator('app-topbar button').first();
|
||||
if (await topbarMenuBtn.count() > 0) {
|
||||
console.log(' Clicking topbar menu button for mobile sidebar...');
|
||||
await topbarMenuBtn.click({ force: true, timeout: 5000 }).catch(() => {});
|
||||
await sleep(1000);
|
||||
|
||||
// Check if mobile menu is now open
|
||||
const afterClick = await mobilePage.evaluate(() => {
|
||||
const shell = document.querySelector('.shell');
|
||||
const sidebar = document.querySelector('app-sidebar');
|
||||
const hostEl = document.querySelector('app-shell');
|
||||
return {
|
||||
shellClasses: shell?.className,
|
||||
hostClasses: hostEl?.className,
|
||||
sidebarTransform: sidebar ? window.getComputedStyle(sidebar).transform : null,
|
||||
};
|
||||
});
|
||||
console.log(` After menu click: ${JSON.stringify(afterClick)}`);
|
||||
|
||||
// Force the mobile menu open via class
|
||||
await mobilePage.evaluate(() => {
|
||||
const hostEl = document.querySelector('app-shell');
|
||||
if (hostEl) {
|
||||
hostEl.classList.add('shell--mobile-open');
|
||||
}
|
||||
// Also try the shell div
|
||||
const shell = document.querySelector('.shell');
|
||||
if (shell) {
|
||||
// Remove translateX from sidebar
|
||||
const sidebar = shell.querySelector('app-sidebar');
|
||||
if (sidebar) {
|
||||
(sidebar).style.transform = 'translateX(0)';
|
||||
}
|
||||
}
|
||||
});
|
||||
await sleep(500);
|
||||
await screenshot(mobilePage, '15-mobile-sidebar-open', 'Mobile viewport with sidebar open');
|
||||
}
|
||||
|
||||
// Navigate to a page on mobile
|
||||
await mobilePage.goto(`${BASE_URL}/findings`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await sleep(2000);
|
||||
await screenshot(mobilePage, '16-mobile-findings', 'Mobile viewport - findings page');
|
||||
|
||||
await mobileContext.close();
|
||||
|
||||
// ===== Part 3: Wide viewport for full sidebar expansion =====
|
||||
console.log('\n[PART 3] Capturing wide viewport with all groups expanded...');
|
||||
const wideContext = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
ignoreHTTPSErrors: true,
|
||||
bypassCSP: true,
|
||||
});
|
||||
const widePage = await wideContext.newPage();
|
||||
|
||||
await widePage.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await sleep(3000);
|
||||
|
||||
// Expand ALL sidebar groups
|
||||
await widePage.evaluate(() => {
|
||||
const sidebar = document.querySelector('app-sidebar');
|
||||
if (!sidebar) return;
|
||||
|
||||
// Click all group headers to expand them
|
||||
const headers = sidebar.querySelectorAll('[class*="group-header"], [class*="nav-group"], button[class*="group"]');
|
||||
headers.forEach(h => {
|
||||
try { h.click(); } catch {}
|
||||
});
|
||||
|
||||
// Also try disclosure buttons or expandable sections
|
||||
const expandables = sidebar.querySelectorAll('details:not([open]), [aria-expanded="false"]');
|
||||
expandables.forEach(el => {
|
||||
try {
|
||||
if (el.tagName === 'DETAILS') {
|
||||
el.setAttribute('open', '');
|
||||
} else {
|
||||
el.click();
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
await sleep(1000);
|
||||
|
||||
// Take full-page screenshot to show all navigation
|
||||
await widePage.screenshot({
|
||||
path: join(SCREENSHOT_DIR, '04a-sidebar-all-expanded-fullpage.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
console.log(' [SCREENSHOT] 04a-sidebar-all-expanded-fullpage.png - Full page with all sidebar groups expanded');
|
||||
|
||||
await wideContext.close();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`\nError: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log('\n=== Done ===');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
647
src/Web/StellaOps.Web/screenshots/auth/capture.mjs
Normal file
647
src/Web/StellaOps.Web/screenshots/auth/capture.mjs
Normal file
@@ -0,0 +1,647 @@
|
||||
/**
|
||||
* 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);
|
||||
Reference in New Issue
Block a user