Preserve topology and triage scope in live setup flows
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
#!/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 webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-setup-topology-action-sweep.json');
|
||||
const authStatePath = path.join(outputDir, 'live-setup-topology-action-sweep.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-setup-topology-action-sweep.auth.json');
|
||||
const topologyScope = {
|
||||
tenant: 'demo-prod',
|
||||
regions: 'us-east',
|
||||
environments: 'stage',
|
||||
timeWindow: '7d',
|
||||
};
|
||||
const topologyScopeQuery = new URLSearchParams(topologyScope).toString();
|
||||
const STEP_TIMEOUT_MS = 30_000;
|
||||
|
||||
function isStaticAsset(url) {
|
||||
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url);
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
return {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
requestFailures: [],
|
||||
responseErrors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function attachRuntimeObservers(page, runtime) {
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
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) => {
|
||||
if (isStaticAsset(request.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.requestFailures.push({
|
||||
page: page.url(),
|
||||
method: request.method(),
|
||||
url: request.url(),
|
||||
error: request.failure()?.errorText ?? 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
if (isStaticAsset(response.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status() >= 400) {
|
||||
runtime.responseErrors.push({
|
||||
page: page.url(),
|
||||
method: response.request().method(),
|
||||
status: response.status(),
|
||||
url: response.url(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function settle(page) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(750);
|
||||
|
||||
const loadingBanners = page.locator('text=/Loading /i');
|
||||
const count = await loadingBanners.count().catch(() => 0);
|
||||
if (count > 0) {
|
||||
await loadingBanners.first().waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
await page.waitForTimeout(750);
|
||||
}
|
||||
|
||||
async function headingText(page) {
|
||||
const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title');
|
||||
const count = await headings.count();
|
||||
for (let index = 0; index < Math.min(count, 5); index += 1) {
|
||||
const text = (await headings.nth(index).innerText().catch(() => '')).trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function captureSnapshot(page, label) {
|
||||
const alerts = await page
|
||||
.locator('[role="alert"], .banner--error, .error-banner, .toast, .notification')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
|
||||
.filter(Boolean)
|
||||
.slice(0, 8),
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
return {
|
||||
label,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
heading: await headingText(page),
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistSummary(summary) {
|
||||
summary.lastUpdatedAtUtc = new Date().toISOString();
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function withScope(route) {
|
||||
const separator = route.includes('?') ? '&' : '?';
|
||||
return `https://stella-ops.local${route}${separator}${topologyScopeQuery}`;
|
||||
}
|
||||
|
||||
async function navigate(page, route) {
|
||||
await page.goto(withScope(route), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
function hasExpectedQuery(urlString, expectedQuery = {}) {
|
||||
const url = new URL(urlString);
|
||||
for (const [key, value] of Object.entries(expectedQuery)) {
|
||||
if (url.searchParams.get(key) !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resolveLink(page, options) {
|
||||
if (options.hrefIncludes) {
|
||||
const candidates = page.locator(`a[href*="${options.hrefIncludes}"]`);
|
||||
const count = await candidates.count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const candidate = candidates.nth(index);
|
||||
const text = ((await candidate.innerText().catch(() => '')) || '').trim();
|
||||
if (!options.name || text === options.name || text.includes(options.name)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.name) {
|
||||
const link = page.getByRole('link', { name: options.name }).first();
|
||||
if ((await link.count()) > 0) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function clickLinkAction(page, route, options) {
|
||||
await navigate(page, route);
|
||||
const link = await resolveLink(page, options);
|
||||
if (!link) {
|
||||
return {
|
||||
action: options.action,
|
||||
ok: false,
|
||||
reason: 'missing-link',
|
||||
snapshot: await captureSnapshot(page, `missing:${options.action}`),
|
||||
};
|
||||
}
|
||||
|
||||
await link.click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
|
||||
const url = new URL(page.url());
|
||||
const ok = url.pathname === options.expectedPath && hasExpectedQuery(page.url(), options.expectedQuery);
|
||||
|
||||
return {
|
||||
action: options.action,
|
||||
ok,
|
||||
finalUrl: page.url(),
|
||||
snapshot: await captureSnapshot(page, `after:${options.action}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function fillOverviewSearch(page) {
|
||||
await navigate(page, '/setup/topology/overview');
|
||||
const input = page.locator('#topology-overview-search');
|
||||
await input.fill('stage');
|
||||
await page.getByRole('button', { name: 'Go' }).click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
|
||||
const ok =
|
||||
new URL(page.url()).pathname === '/setup/topology/environments/stage/posture' &&
|
||||
hasExpectedQuery(page.url(), topologyScope);
|
||||
|
||||
return {
|
||||
action: 'overview-search:Go',
|
||||
ok,
|
||||
finalUrl: page.url(),
|
||||
snapshot: await captureSnapshot(page, 'after:overview-search:Go'),
|
||||
};
|
||||
}
|
||||
|
||||
async function clickEnvironmentDetailTab(page, tabLabel, expectedText) {
|
||||
await navigate(page, '/setup/topology/environments/stage/posture');
|
||||
await page.getByRole('button', { name: tabLabel, exact: true }).click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
|
||||
const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false);
|
||||
return {
|
||||
action: `environment-tab:${tabLabel}`,
|
||||
ok,
|
||||
finalUrl: page.url(),
|
||||
snapshot: await captureSnapshot(page, `after:environment-tab:${tabLabel}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyEmptyInventoryState(page, route, expectedText) {
|
||||
await navigate(page, route);
|
||||
const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false);
|
||||
return {
|
||||
action: `empty-state:${route}`,
|
||||
ok,
|
||||
finalUrl: page.url(),
|
||||
snapshot: await captureSnapshot(page, `after:empty-state:${route}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function runAction(page, route, actionFactory) {
|
||||
const startedAtUtc = new Date().toISOString();
|
||||
const startedAt = Date.now();
|
||||
process.stdout.write(`[live-setup-topology-action-sweep] START ${route}\n`);
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
actionFactory(page, route),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Timed out after ${STEP_TIMEOUT_MS}ms.`)), STEP_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
|
||||
const completed = {
|
||||
...result,
|
||||
startedAtUtc,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
process.stdout.write(
|
||||
`[live-setup-topology-action-sweep] DONE ${completed.action} ok=${completed.ok} durationMs=${completed.durationMs}\n`,
|
||||
);
|
||||
return completed;
|
||||
} catch (error) {
|
||||
const failed = {
|
||||
action: route,
|
||||
ok: false,
|
||||
reason: 'exception',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
startedAtUtc,
|
||||
durationMs: Date.now() - startedAt,
|
||||
snapshot: await captureSnapshot(page, `failure:${route}`),
|
||||
};
|
||||
process.stdout.write(
|
||||
`[live-setup-topology-action-sweep] FAIL ${route} error=${failed.error} durationMs=${failed.durationMs}\n`,
|
||||
);
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const authReport = await authenticateFrontdoor({
|
||||
statePath: authStatePath,
|
||||
reportPath: authReportPath,
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const context = await createAuthenticatedContext(browser, authReport, {
|
||||
statePath: authStatePath,
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
context.on('page', (page) => attachRuntimeObservers(page, runtime));
|
||||
|
||||
const page = await context.newPage();
|
||||
attachRuntimeObservers(page, runtime);
|
||||
|
||||
const actions = [
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Map',
|
||||
hrefIncludes: '/setup/topology/map',
|
||||
expectedPath: '/setup/topology/map',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Regions & Environments',
|
||||
hrefIncludes: '/setup/topology/regions',
|
||||
expectedPath: '/setup/topology/regions',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Targets',
|
||||
name: 'Targets',
|
||||
hrefIncludes: '/setup/topology/targets',
|
||||
expectedPath: '/setup/topology/targets',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Hosts',
|
||||
hrefIncludes: '/setup/topology/hosts',
|
||||
expectedPath: '/setup/topology/hosts',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Agents',
|
||||
hrefIncludes: '/setup/topology/agents',
|
||||
expectedPath: '/setup/topology/agents',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Promotion Graph',
|
||||
hrefIncludes: '/setup/topology/promotion-graph',
|
||||
expectedPath: '/setup/topology/promotion-graph',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Workflows',
|
||||
hrefIncludes: '/setup/topology/workflows',
|
||||
expectedPath: '/setup/topology/workflows',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Gate Profiles',
|
||||
hrefIncludes: '/setup/topology/gate-profiles',
|
||||
expectedPath: '/setup/topology/gate-profiles',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Connectivity',
|
||||
hrefIncludes: '/setup/topology/connectivity',
|
||||
expectedPath: '/setup/topology/connectivity',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'tab:Runtime Drift',
|
||||
hrefIncludes: '/setup/topology/runtime-drift',
|
||||
expectedPath: '/setup/topology/runtime-drift',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'overview:Open Regions & Environments',
|
||||
name: 'Open Regions & Environments',
|
||||
expectedPath: '/setup/topology/regions',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'overview:Open Environment Inventory',
|
||||
name: 'Open Environment Inventory',
|
||||
expectedPath: '/setup/topology/environments',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'overview:Open Agents',
|
||||
name: 'Open Agents',
|
||||
expectedPath: '/setup/topology/agents',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
|
||||
action: 'overview:Open Promotion Paths',
|
||||
name: 'Open Promotion Paths',
|
||||
expectedPath: '/setup/topology/promotion-graph',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/overview', (currentPage) => fillOverviewSearch(currentPage)],
|
||||
['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
|
||||
action: 'environments:Open',
|
||||
name: 'Open',
|
||||
hrefIncludes: '/setup/topology/environments/stage/posture',
|
||||
expectedPath: '/setup/topology/environments/stage/posture',
|
||||
expectedQuery: topologyScope,
|
||||
})],
|
||||
['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
|
||||
action: 'environments:Open Targets',
|
||||
name: 'Open Targets',
|
||||
hrefIncludes: '/setup/topology/targets',
|
||||
expectedPath: '/setup/topology/targets',
|
||||
expectedQuery: {
|
||||
...topologyScope,
|
||||
environment: 'stage',
|
||||
},
|
||||
})],
|
||||
['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
|
||||
action: 'environments:Open Agents',
|
||||
name: 'Open Agents',
|
||||
hrefIncludes: '/setup/topology/agents',
|
||||
expectedPath: '/setup/topology/agents',
|
||||
expectedQuery: {
|
||||
...topologyScope,
|
||||
environment: 'stage',
|
||||
},
|
||||
})],
|
||||
['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
|
||||
action: 'environments:Open Runs',
|
||||
name: 'Open Runs',
|
||||
hrefIncludes: '/releases/runs',
|
||||
expectedPath: '/releases/runs',
|
||||
expectedQuery: {
|
||||
...topologyScope,
|
||||
environment: 'stage',
|
||||
},
|
||||
})],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Overview', 'Operator Actions')],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Targets', 'Targets')],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Runs', 'Runs')],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Agents', 'Agents')],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Security', 'Security')],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Evidence', 'Evidence')],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Data Quality', 'Data Quality')],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
|
||||
action: 'environment-detail:Open Targets',
|
||||
name: 'Open Targets',
|
||||
hrefIncludes: '/setup/topology/targets',
|
||||
expectedPath: '/setup/topology/targets',
|
||||
expectedQuery: {
|
||||
...topologyScope,
|
||||
environment: 'stage',
|
||||
},
|
||||
})],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
|
||||
action: 'environment-detail:Open Agents',
|
||||
name: 'Open Agents',
|
||||
hrefIncludes: '/setup/topology/agents',
|
||||
expectedPath: '/setup/topology/agents',
|
||||
expectedQuery: {
|
||||
...topologyScope,
|
||||
environment: 'stage',
|
||||
},
|
||||
})],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
|
||||
action: 'environment-detail:Open Runs',
|
||||
name: 'Open Runs',
|
||||
hrefIncludes: '/releases/runs',
|
||||
expectedPath: '/releases/runs',
|
||||
expectedQuery: {
|
||||
...topologyScope,
|
||||
environment: 'stage',
|
||||
},
|
||||
})],
|
||||
['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
|
||||
action: 'environment-detail:Open Security Triage',
|
||||
name: 'Open Security Triage',
|
||||
hrefIncludes: '/security/triage',
|
||||
expectedPath: '/security/triage',
|
||||
expectedQuery: {
|
||||
...topologyScope,
|
||||
environment: 'stage',
|
||||
},
|
||||
})],
|
||||
['/setup/topology/targets', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/targets', 'No targets for current filters.')],
|
||||
['/setup/topology/hosts', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/hosts', 'No hosts for current filters.')],
|
||||
['/setup/topology/agents', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/agents', 'No groups for current filters.')],
|
||||
];
|
||||
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl: 'https://stella-ops.local',
|
||||
scope: topologyScope,
|
||||
actions: [],
|
||||
runtime: runtime,
|
||||
};
|
||||
|
||||
for (const [route, actionFactory] of actions) {
|
||||
const result = await runAction(page, route, actionFactory);
|
||||
summary.actions.push(result);
|
||||
await persistSummary(summary);
|
||||
}
|
||||
|
||||
summary.failedActionCount = summary.actions.filter((action) => !action.ok).length;
|
||||
summary.runtimeIssueCount =
|
||||
runtime.consoleErrors.length +
|
||||
runtime.pageErrors.length +
|
||||
runtime.requestFailures.length +
|
||||
runtime.responseErrors.length;
|
||||
|
||||
await persistSummary(summary);
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
if (summary.failedActionCount > 0 || summary.runtimeIssueCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in New Issue
Block a user