#!/usr/bin/env node import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const webRoot = path.resolve(__dirname, '..'); const outputDirectory = path.join(webRoot, 'output', 'playwright'); const DEFAULT_BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; const DEFAULT_USERNAME = process.env.STELLAOPS_FRONTDOOR_USERNAME?.trim() || 'admin'; const DEFAULT_PASSWORD = process.env.STELLAOPS_FRONTDOOR_PASSWORD?.trim() || 'Admin@Stella2026!'; const DEFAULT_STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json'); const DEFAULT_REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json'); function createLocator(page, selectors) { return page.locator(selectors.join(', ')).first(); } async function clickIfVisible(locator, timeoutMs = 5_000) { if (!(await locator.isVisible().catch(() => false))) { return false; } await locator.click({ timeout: timeoutMs, noWaitAfter: true }).catch(() => {}); return true; } async function fillIfVisible(locator, value) { if (!(await locator.isVisible().catch(() => false))) { return false; } await locator.fill(value); return true; } async function waitForShell(page) { const shellMarkers = [ page.locator('app-topbar'), page.locator('aside.sidebar'), page.locator('app-shell'), page.locator('app-root'), ]; for (const marker of shellMarkers) { if (await marker.first().isVisible().catch(() => false)) { return; } } await Promise.race([ ...shellMarkers.map((marker) => marker.first().waitFor({ state: 'visible', timeout: 15_000 }).catch(() => {})), page.waitForTimeout(15_000), ]); } async function waitForAuthTransition(page, usernameField, passwordField, timeoutMs = 10_000) { await Promise.race([ page.waitForURL((url) => url.toString().includes('/connect/authorize') || url.toString().includes('/auth/callback'), { timeout: timeoutMs, }).catch(() => {}), usernameField.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {}), passwordField.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {}), page.waitForFunction(() => Boolean(sessionStorage.getItem('stellaops.auth.session.full')), null, { timeout: timeoutMs, }).catch(() => {}), page.waitForTimeout(timeoutMs), ]); } export async function authenticateFrontdoor(options = {}) { const baseUrl = options.baseUrl?.trim() || DEFAULT_BASE_URL; const username = options.username?.trim() || DEFAULT_USERNAME; const password = options.password?.trim() || DEFAULT_PASSWORD; const statePath = options.statePath || DEFAULT_STATE_PATH; const reportPath = options.reportPath || DEFAULT_REPORT_PATH; const headless = options.headless ?? true; mkdirSync(path.dirname(statePath), { recursive: true }); mkdirSync(path.dirname(reportPath), { recursive: true }); const browser = await chromium.launch({ headless, args: ['--disable-dev-shm-usage'], }); const context = await browser.newContext({ ignoreHTTPSErrors: true }); const page = await context.newPage(); const events = { consoleErrors: [], requestFailures: [], responseErrors: [], }; page.on('console', (message) => { if (message.type() === 'error') { events.consoleErrors.push(message.text()); } }); page.on('requestfailed', (request) => { const url = request.url(); if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { return; } events.requestFailures.push({ method: request.method(), url, error: request.failure()?.errorText ?? 'unknown', page: page.url(), }); }); page.on('response', (response) => { const url = response.url(); if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { return; } if (response.status() >= 400) { events.responseErrors.push({ status: response.status(), method: response.request().method(), url, page: page.url(), }); } }); await page.goto(`${baseUrl}/welcome`, { waitUntil: 'domcontentloaded', timeout: 30_000, }); await page.waitForTimeout(1_500); const signInTrigger = createLocator(page, [ 'button:has-text("Sign In")', 'button:has-text("Sign in")', 'a:has-text("Sign In")', 'a:has-text("Sign in")', 'button.cta', ]); const usernameField = createLocator(page, [ 'input[name="username"]', 'input[name="Username"]', 'input[type="text"]', 'input[type="email"]', ]); const passwordField = createLocator(page, [ 'input[name="password"]', 'input[name="Password"]', 'input[type="password"]', ]); const signInClicked = await clickIfVisible(signInTrigger); if (signInClicked) { await waitForAuthTransition(page, usernameField, passwordField); } else { await page.waitForTimeout(1_500); } const hasLoginForm = (await usernameField.count()) > 0 && (await passwordField.count()) > 0; if (page.url().includes('/connect/authorize') || hasLoginForm) { const filledUser = await fillIfVisible(usernameField, username); const filledPassword = await fillIfVisible(passwordField, password); if (!filledUser || !filledPassword) { throw new Error(`Authority login form was reached at ${page.url()} but the credentials fields were not interactable.`); } const submitButton = createLocator(page, [ 'button[type="submit"]', 'button:has-text("Sign In")', 'button:has-text("Sign in")', 'button:has-text("Log in")', 'button:has-text("Login")', ]); await submitButton.click({ timeout: 10_000 }); await Promise.race([ page.waitForURL( (url) => !url.toString().includes('/connect/authorize') && !url.toString().includes('/auth/callback'), { timeout: 30_000 }, ).catch(() => {}), page.waitForFunction(() => Boolean(sessionStorage.getItem('stellaops.auth.session.full')), null, { timeout: 30_000, }).catch(() => {}), ]); } await waitForShell(page); await page.waitForTimeout(2_500); const sessionStatus = await page.evaluate(() => ({ hasFullSession: Boolean(sessionStorage.getItem('stellaops.auth.session.full')), hasSessionInfo: Boolean(sessionStorage.getItem('stellaops.auth.session.info')), })); const signInStillVisible = await signInTrigger.isVisible().catch(() => false); if (!sessionStatus.hasFullSession || (!page.url().includes('/connect/authorize') && signInStillVisible)) { throw new Error( `Frontdoor authentication did not establish a Stella Ops session. finalUrl=${page.url()} signInVisible=${signInStillVisible}`, ); } await context.storageState({ path: statePath }); const report = { authenticatedAtUtc: new Date().toISOString(), baseUrl, finalUrl: page.url(), title: await page.title(), cookies: (await context.cookies()).map((cookie) => ({ name: cookie.name, domain: cookie.domain, path: cookie.path, secure: cookie.secure, sameSite: cookie.sameSite, })), storage: await page.evaluate(() => ({ localStorageEntries: [...Array(localStorage.length)] .map((_, index) => localStorage.key(index)) .filter(Boolean) .map((key) => [key, localStorage.getItem(key)]), sessionStorageEntries: [...Array(sessionStorage.length)] .map((_, index) => sessionStorage.key(index)) .filter(Boolean) .map((key) => [key, sessionStorage.getItem(key)]), })), events, statePath, }; writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); await browser.close(); return report; } export function getSessionStorageEntries(authReport) { return Array.isArray(authReport?.storage?.sessionStorageEntries) ? authReport.storage.sessionStorageEntries.filter( (entry) => Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'string', ) : []; } export async function addSessionStorageInitScript(context, authReport) { const sessionEntries = getSessionStorageEntries(authReport); await context.addInitScript((entries) => { sessionStorage.clear(); for (const [key, value] of entries) { if (typeof key === 'string' && typeof value === 'string') { sessionStorage.setItem(key, value); } } }, sessionEntries); } export async function createAuthenticatedContext(browser, authReport, options = {}) { const context = await browser.newContext({ ignoreHTTPSErrors: true, storageState: options.statePath || DEFAULT_STATE_PATH, ...options.contextOptions, }); await addSessionStorageInitScript(context, authReport); return context; } async function main() { const report = await authenticateFrontdoor(); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { main().catch((error) => { process.stderr.write(`[live-frontdoor-auth] ${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); }); }