561 lines
17 KiB
JavaScript
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;
|
|
});
|