#!/usr/bin/env node import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const webRoot = path.resolve(__dirname, '..'); const outputDir = path.join(webRoot, 'output', 'playwright'); const outputPath = path.join(outputDir, 'live-setup-wizard-full-bootstrap.json'); const authStatePath = path.join(outputDir, 'live-setup-wizard-full-bootstrap.state.json'); const authReportPath = path.join(outputDir, 'live-setup-wizard-full-bootstrap.auth.json'); const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; const wizardUrl = `${baseUrl}/setup-wizard/wizard?mode=reconfigure`; const headless = (process.env.STELLAOPS_UI_BOOTSTRAP_HEADLESS || 'true').toLowerCase() !== 'false'; function isStaticAsset(url) { return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url); } function createRuntime() { return { consoleErrors: [], pageErrors: [], requestFailures: [], responseErrors: [], }; } function normalizeStepId(value) { switch ((value ?? '').toString().trim().toLowerCase()) { case 'database': return 'database'; case 'valkey': case 'cache': return 'cache'; case 'migrations': return 'migrations'; case 'admin': return 'admin'; case 'crypto': return 'crypto'; default: return (value ?? '').toString().trim().toLowerCase(); } } function normalizeStepStatus(value) { switch ((value ?? '').toString().trim().toLowerCase()) { case 'inprogress': return 'in_progress'; case 'pass': case 'passed': return 'completed'; default: return (value ?? '').toString().trim().toLowerCase(); } } function attachRuntime(page, runtime) { page.on('console', (message) => { if ( message.type() === 'error' && !message.text().startsWith('Failed to load resource: the server responded with a status of') ) { runtime.consoleErrors.push({ page: page.url(), text: message.text() }); } }); page.on('pageerror', (error) => { if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) { return; } runtime.pageErrors.push({ page: page.url(), text: error instanceof Error ? error.message : String(error), }); }); page.on('requestfailed', (request) => { const errorText = request.failure()?.errorText ?? 'unknown'; if ( isStaticAsset(request.url()) || errorText === 'net::ERR_ABORTED' || (!request.url().includes('/api/v1/setup') && !request.url().includes('/setup-wizard')) ) { return; } runtime.requestFailures.push({ page: page.url(), method: request.method(), url: request.url(), error: errorText, }); }); page.on('response', (response) => { if ( isStaticAsset(response.url()) || response.url().includes('/api/v1/stella-assistant/tips') || (!response.url().includes('/api/v1/setup') && !response.url().includes('/setup-wizard')) ) { return; } if (response.status() >= 400) { runtime.responseErrors.push({ page: page.url(), method: response.request().method(), status: response.status(), url: response.url(), }); } }); } function cleanText(value) { return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''; } async function settle(page, ms = 1500) { await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {}); await page.waitForTimeout(ms); } async function captureSnapshot(page, label) { const alerts = await page .locator('[role="alert"], .error-banner, .warning-banner, .banner, .toast, .notification, .status-card, .test-result') .evaluateAll((nodes) => nodes .map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()) .filter(Boolean) .slice(0, 10), ) .catch(() => []); const visibleButtons = await page .locator('button') .evaluateAll((nodes) => nodes .map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()) .filter(Boolean) .slice(0, 12), ) .catch(() => []); return { label, url: page.url(), title: await page.title().catch(() => ''), heading: cleanText(await page.locator('h1, h2').first().textContent().catch(() => '')), alerts, visibleButtons, }; } async function readCurrentSession(page) { const result = await page.evaluate(async () => { const sessionPayload = localStorage.getItem('stellaops.auth.session.full') ?? sessionStorage.getItem('stellaops.auth.session.full'); const accessToken = sessionPayload ? JSON.parse(sessionPayload)?.tokens?.accessToken ?? null : null; const response = await fetch('/api/v1/setup/sessions/current', { credentials: 'include', headers: { Accept: 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }); const bodyText = await response.text(); let body = null; try { body = JSON.parse(bodyText); } catch { body = null; } return { status: response.status, ok: response.ok, body, bodyText: bodyText.slice(0, 4000), }; }); const session = result?.body?.session ?? null; return { status: result.status, ok: result.ok, sessionId: session?.sessionId ?? null, currentStepId: normalizeStepId(session?.currentStepId ?? null), sessionStatus: normalizeStepStatus(session?.status ?? null), steps: Array.isArray(session?.steps) ? session.steps.map((step) => ({ stepId: normalizeStepId(step.stepId), status: normalizeStepStatus(step.status), lastProbeSucceeded: step.lastProbeSucceeded ?? null, errorMessage: step.errorMessage ?? null, })) : [], raw: result, }; } async function restartSetupSession(page) { return page.evaluate(async () => { const sessionPayload = localStorage.getItem('stellaops.auth.session.full') ?? sessionStorage.getItem('stellaops.auth.session.full'); const accessToken = sessionPayload ? JSON.parse(sessionPayload)?.tokens?.accessToken ?? null : null; const response = await fetch('/api/v1/setup/sessions', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, body: JSON.stringify({ forceRestart: true }), }); const bodyText = await response.text(); let body = null; try { body = JSON.parse(bodyText); } catch { body = null; } return { status: response.status, ok: response.ok, body, bodyText: bodyText.slice(0, 4000), }; }); } async function waitForCurrentStep(page, expectedStepId) { await page.waitForFunction( async (stepId) => { const sessionPayload = localStorage.getItem('stellaops.auth.session.full') ?? sessionStorage.getItem('stellaops.auth.session.full'); const accessToken = sessionPayload ? JSON.parse(sessionPayload)?.tokens?.accessToken ?? null : null; const response = await fetch('/api/v1/setup/sessions/current', { credentials: 'include', headers: { Accept: 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }); if (!response.ok) { return false; } const body = await response.json().catch(() => null); return body?.session?.currentStepId === stepId; }, expectedStepId, { timeout: 30_000 }, ); await page.waitForTimeout(1000); } async function waitForSessionStatus(page, expectedStatus) { await page.waitForFunction( async (status) => { const sessionPayload = localStorage.getItem('stellaops.auth.session.full') ?? sessionStorage.getItem('stellaops.auth.session.full'); const accessToken = sessionPayload ? JSON.parse(sessionPayload)?.tokens?.accessToken ?? null : null; const response = await fetch('/api/v1/setup/sessions/current', { credentials: 'include', headers: { Accept: 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }); if (!response.ok) { return false; } const body = await response.json().catch(() => null); return (body?.session?.status || '').toString().toLowerCase() === status; }, expectedStatus, { timeout: 30_000 }, ); await page.waitForTimeout(1000); } async function fillIfVisible(page, selector, value) { const locator = page.locator(selector); if (!(await locator.isVisible().catch(() => false))) { return false; } await locator.fill(value); await page.waitForTimeout(150); return true; } async function ensureFieldValue(page, selector, value) { const locator = page.locator(selector); if (!(await locator.isVisible().catch(() => false))) { return false; } const currentValue = await locator.inputValue().catch(() => ''); if (cleanText(currentValue) === cleanText(value) && cleanText(currentValue) !== '') { return true; } if (cleanText(currentValue) === '') { await locator.fill(value); await page.waitForTimeout(150); return true; } await locator.fill(value); await page.waitForTimeout(150); return true; } async function clickPrimaryAction(page, namePattern) { const button = page.getByRole('button', { name: namePattern }).first(); await button.click({ timeout: 10_000 }); } async function applyStep(page, currentStepId, nextStepId) { await Promise.all([ page.waitForResponse( (response) => response.request().method() === 'POST' && response.url().includes('/api/v1/setup/sessions/') && response.url().includes(`/steps/${currentStepId}/apply`), { timeout: 30_000 }, ), clickPrimaryAction(page, /Apply and Continue/i), ]); if (nextStepId) { await waitForCurrentStep(page, nextStepId); } else { await waitForSessionStatus(page, 'completed'); } } async function validateDatabase(page) { const validateButton = page.getByRole('button', { name: /^Validate Connection$/ }).first(); if (!(await validateButton.isVisible().catch(() => false))) { return false; } await Promise.all([ page.waitForResponse( (response) => response.request().method() === 'POST' && response.url().includes('/api/v1/setup/sessions/') && response.url().includes('/steps/database/probe'), { timeout: 30_000 }, ), validateButton.click({ timeout: 10_000 }), ]); await settle(page, 1500); return true; } async function chooseDefaultCryptoProvider(page) { const target = page.getByRole('button', { name: /Default \(Recommended\)/i }).first(); if (!(await target.isVisible().catch(() => false))) { return false; } await target.click({ timeout: 10_000 }); await page.waitForTimeout(300); return true; } async function main() { await mkdir(outputDir, { recursive: true }); const authReport = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath, headless, }); const browser = await chromium.launch({ headless, args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'], }); const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath, }); const page = await context.newPage(); const runtime = createRuntime(); attachRuntime(page, runtime); const results = []; await page.goto(`${baseUrl}/welcome`, { waitUntil: 'domcontentloaded', timeout: 30_000 }); await settle(page, 1000); const forcedSession = await restartSetupSession(page); await page.goto(wizardUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }); await settle(page, 2500); await page.getByRole('button', { name: 'Start Setup', exact: true }).waitFor({ state: 'visible', timeout: 20_000 }); const welcomeSession = await readCurrentSession(page); results.push({ action: 'force-restart-to-welcome', ok: forcedSession.ok && welcomeSession.currentStepId === 'database', forcedSession, session: welcomeSession, snapshot: await captureSnapshot(page, 'welcome'), }); await clickPrimaryAction(page, /^Start Setup$/); await page.waitForFunction( () => (document.body?.innerText || '').replace(/\s+/g, ' ').includes('PostgreSQL Connection'), { timeout: 30_000 }, ); await settle(page, 1000); const databaseValidated = await validateDatabase(page); await applyStep(page, 'database', 'cache'); results.push({ action: 'database-step-completed', ok: (await readCurrentSession(page)).currentStepId === 'cache', validated: databaseValidated, session: await readCurrentSession(page), snapshot: await captureSnapshot(page, 'database-complete'), }); await page.waitForFunction( () => (document.body?.innerText || '').replace(/\s+/g, ' ').includes('Valkey/Redis Connection'), { timeout: 30_000 }, ); await settle(page, 750); await applyStep(page, 'cache', 'migrations'); results.push({ action: 'cache-step-completed', ok: (await readCurrentSession(page)).currentStepId === 'migrations', session: await readCurrentSession(page), snapshot: await captureSnapshot(page, 'cache-complete'), }); await page.waitForFunction( () => (document.body?.innerText || '').replace(/\s+/g, ' ').includes('Database Migrations'), { timeout: 30_000 }, ); await settle(page, 1000); await applyStep(page, 'migrations', 'admin'); results.push({ action: 'migrations-step-completed', ok: (await readCurrentSession(page)).currentStepId === 'admin', session: await readCurrentSession(page), snapshot: await captureSnapshot(page, 'migrations-complete'), }); await page.waitForFunction( () => (document.body?.innerText || '').replace(/\s+/g, ' ').includes('Super User Account'), { timeout: 30_000 }, ); await settle(page, 750); await ensureFieldValue(page, '#users-superuser-username', 'admin'); await ensureFieldValue(page, '#users-superuser-email', 'admin@stella-ops.local'); await ensureFieldValue(page, '#users-superuser-password', 'Admin@Stella1'); await applyStep(page, 'admin', 'crypto'); results.push({ action: 'admin-step-completed', ok: (await readCurrentSession(page)).currentStepId === 'crypto', session: await readCurrentSession(page), snapshot: await captureSnapshot(page, 'admin-complete'), }); await page.waitForFunction( () => (document.body?.innerText || '').replace(/\s+/g, ' ').includes('Cryptographic Provider'), { timeout: 30_000 }, ); await settle(page, 750); const cryptoSelected = await chooseDefaultCryptoProvider(page); await Promise.all([ page.waitForResponse( (response) => response.request().method() === 'POST' && response.url().includes('/api/v1/setup/sessions/') && response.url().includes('/finalize'), { timeout: 30_000 }, ), clickPrimaryAction(page, /^Finish Setup$/), ]); await Promise.race([ page.waitForURL((url) => !url.pathname.includes('/setup-wizard/wizard'), { timeout: 30_000 }), waitForSessionStatus(page, 'completed'), ]); await settle(page, 1500); const finalSession = await readCurrentSession(page); results.push({ action: 'crypto-finalize-completed', ok: cryptoSelected && finalSession.sessionStatus === 'completed', cryptoSelected, session: finalSession, snapshot: await captureSnapshot(page, 'finalized'), }); const runtimeIssueCount = runtime.consoleErrors.length + runtime.pageErrors.length + runtime.requestFailures.length + runtime.responseErrors.length; const failedActionCount = results.filter((entry) => !entry.ok).length; const summary = { generatedAtUtc: new Date().toISOString(), failedActionCount, runtimeIssueCount, currentUrl: page.url(), results, finalSession, runtime, }; await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); await context.close(); await browser.close(); if (failedActionCount > 0 || runtimeIssueCount > 0) { process.exitCode = 1; } } main().catch(async (error) => { await mkdir(outputDir, { recursive: true }); const summary = { generatedAtUtc: new Date().toISOString(), fatalError: error instanceof Error ? error.message : String(error), }; await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); console.error(error); process.exitCode = 1; });