feat(web): setup wizard / integrations hub / release environments UI
Rewire the setup wizard UI to the persistent session endpoints: resume-aware state service, truthful step status (draft / applying / applied / failed), and wizard shell that no longer treats test-connection as completion. Refresh the integrations hub to expose Secrets / Feed Mirrors / Object Storage categories and align the onboarding wizard validation with the backend contract for optional-auth local connectors. Modernize the release-orchestrator environments pages against the new environment/target API (models + client), plus adjacent navigation, route-surface, and test-surface refresh. Add Playwright harnesses for live setup-wizard bootstrap / integrations bootstrap / state truth checks, and commit their evidence. Closes UISETUP-* from SPRINT_20260413_003 and the UI-facing tasks of SPRINT_20260413_004. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
#!/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$/),
|
||||
]);
|
||||
|
||||
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);
|
||||
|
||||
await ensureFieldValue(page, '#db-host', 'db.stella-ops.local');
|
||||
await ensureFieldValue(page, '#db-port', '5432');
|
||||
await ensureFieldValue(page, '#db-database', 'stellaops_platform');
|
||||
await ensureFieldValue(page, '#db-user', 'stellaops');
|
||||
await ensureFieldValue(page, '#db-password', 'stellaops');
|
||||
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 ensureFieldValue(page, '#cache-host', 'cache.stella-ops.local');
|
||||
await ensureFieldValue(page, '#cache-port', '6379');
|
||||
await fillIfVisible(page, '#cache-password', '');
|
||||
await ensureFieldValue(page, '#cache-database', '0');
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user