Files
git.stella-ops.org/src/Web/StellaOps.Web/scripts/live-setup-wizard-full-bootstrap.mjs
2026-04-14 21:44:35 +03:00

561 lines
17 KiB
JavaScript

#!/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;
});