398 lines
12 KiB
JavaScript
398 lines
12 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 webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const outputDir = path.join(webRoot, 'output', 'playwright');
|
|
const outputPath = path.join(outputDir, 'live-mission-control-action-sweep.json');
|
|
const authStatePath = path.join(outputDir, 'live-mission-control-action-sweep.state.json');
|
|
const authReportPath = path.join(outputDir, 'live-mission-control-action-sweep.auth.json');
|
|
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
|
const STEP_TIMEOUT_MS = 30_000;
|
|
const ELEMENT_WAIT_MS = 8_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;
|
|
}
|
|
|
|
const errorText = request.failure()?.errorText ?? 'unknown';
|
|
if (errorText === 'net::ERR_ABORTED') {
|
|
return;
|
|
}
|
|
|
|
runtime.requestFailures.push({
|
|
page: page.url(),
|
|
method: request.method(),
|
|
url: request.url(),
|
|
error: errorText,
|
|
});
|
|
});
|
|
|
|
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(1_500);
|
|
}
|
|
|
|
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, 4); 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"], .mat-mdc-snack-bar-container, .toast, .notification, .error-banner')
|
|
.evaluateAll((nodes) =>
|
|
nodes
|
|
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
|
|
.filter(Boolean)
|
|
.slice(0, 5),
|
|
)
|
|
.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');
|
|
}
|
|
|
|
async function navigate(page, route) {
|
|
const separator = route.includes('?') ? '&' : '?';
|
|
await page.goto(`https://stella-ops.local${route}${separator}${scopeQuery}`, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 30_000,
|
|
});
|
|
await settle(page);
|
|
}
|
|
|
|
async function resolveLink(page, options, timeoutMs = ELEMENT_WAIT_MS) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (Date.now() < deadline) {
|
|
const anchors = page.locator('a');
|
|
const anchorCount = await anchors.count();
|
|
for (let index = 0; index < anchorCount; index += 1) {
|
|
const candidate = anchors.nth(index);
|
|
const href = ((await candidate.getAttribute('href').catch(() => '')) || '').trim();
|
|
const text = ((await candidate.innerText().catch(() => '')) || '').trim();
|
|
|
|
if (options.hrefIncludes && !href.includes(options.hrefIncludes)) {
|
|
continue;
|
|
}
|
|
|
|
if (options.name && !(text === options.name || text.includes(options.name))) {
|
|
continue;
|
|
}
|
|
|
|
if (href || text) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
if (!options.hrefIncludes && options.name) {
|
|
const roleLocator = page.getByRole('link', { name: options.name }).first();
|
|
if ((await roleLocator.count()) > 0) {
|
|
return roleLocator;
|
|
}
|
|
|
|
const textLocator = page.locator('a', { hasText: options.name }).first();
|
|
if ((await textLocator.count()) > 0) {
|
|
return textLocator;
|
|
}
|
|
}
|
|
|
|
await page.waitForTimeout(250);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function clickExpectedLink(page, route, options) {
|
|
await navigate(page, route);
|
|
const locator = await resolveLink(page, options);
|
|
if (!locator) {
|
|
return {
|
|
action: options.action,
|
|
ok: false,
|
|
reason: 'missing-link',
|
|
snapshot: await captureSnapshot(page, `missing:${options.action}`),
|
|
};
|
|
}
|
|
|
|
await locator.click({ timeout: 10_000 });
|
|
await settle(page);
|
|
const currentUrl = new URL(page.url());
|
|
const expectedPath = options.expectedPath;
|
|
const searchParams = currentUrl.searchParams;
|
|
|
|
let ok = currentUrl.pathname === expectedPath;
|
|
if (ok && options.expectQuery) {
|
|
for (const [key, value] of Object.entries(options.expectQuery)) {
|
|
if (searchParams.get(key) !== value) {
|
|
ok = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
action: options.action,
|
|
ok,
|
|
finalUrl: page.url(),
|
|
snapshot: await captureSnapshot(page, `after:${options.action}`),
|
|
};
|
|
}
|
|
|
|
async function runAction(page, route, options) {
|
|
const startedAtUtc = new Date().toISOString();
|
|
const startedAt = Date.now();
|
|
process.stdout.write(`[live-mission-control-action-sweep] START ${route} -> ${options.action}\n`);
|
|
|
|
try {
|
|
const result = await Promise.race([
|
|
clickExpectedLink(page, route, options),
|
|
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-mission-control-action-sweep] DONE ${route} -> ${options.action} ok=${completed.ok} durationMs=${completed.durationMs}\n`,
|
|
);
|
|
return completed;
|
|
} catch (error) {
|
|
const failed = {
|
|
action: options.action,
|
|
ok: false,
|
|
reason: 'exception',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
startedAtUtc,
|
|
durationMs: Date.now() - startedAt,
|
|
snapshot: await captureSnapshot(page, `failure:${options.action}`),
|
|
};
|
|
process.stdout.write(
|
|
`[live-mission-control-action-sweep] FAIL ${route} -> ${options.action} 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 summary = {
|
|
generatedAtUtc: new Date().toISOString(),
|
|
results: [],
|
|
runtime,
|
|
};
|
|
|
|
const actionGroups = [
|
|
{
|
|
route: '/mission-control/board',
|
|
actions: [
|
|
{ action: 'link:View all', name: 'View all', hrefIncludes: '/releases/runs', expectedPath: '/releases/runs' },
|
|
{ action: 'link:Review', name: 'Review', expectedPath: '/releases/approvals' },
|
|
{ action: 'link:Risk detail', name: 'Risk detail', expectedPath: '/security' },
|
|
{ action: 'link:Ops detail', name: 'Ops detail', expectedPath: '/ops/operations/data-integrity' },
|
|
{ action: 'link:All environments', name: 'All environments', expectedPath: '/setup/topology/environments' },
|
|
{
|
|
action: 'link:Stage detail',
|
|
name: 'Detail',
|
|
hrefIncludes: '/setup/topology/environments/stage/posture',
|
|
expectedPath: '/setup/topology/environments/stage/posture',
|
|
expectQuery: { environment: 'stage', region: 'us-east' },
|
|
},
|
|
{
|
|
action: 'link:Stage findings',
|
|
name: 'Findings',
|
|
hrefIncludes: '/security/findings?tenant=demo-prod®ions=us-east&environments=stage',
|
|
expectedPath: '/security/findings',
|
|
expectQuery: { environment: 'stage', region: 'us-east' },
|
|
},
|
|
{
|
|
action: 'link:Risk table open stage',
|
|
name: 'Open',
|
|
hrefIncludes: '/setup/topology/environments/stage/posture',
|
|
expectedPath: '/setup/topology/environments/stage/posture',
|
|
expectQuery: { environment: 'stage', region: 'us-east' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
route: '/mission-control/alerts',
|
|
actions: [
|
|
{
|
|
action: 'link:Approvals blocked',
|
|
name: '3 approvals blocked by policy gate evidence freshness',
|
|
expectedPath: '/releases/approvals',
|
|
},
|
|
{
|
|
action: 'link:Watchlist alert',
|
|
name: 'Identity watchlist alert requires signer review',
|
|
hrefIncludes: 'alertId=alert-001&returnTo=%2Fmission-control%2Falerts',
|
|
expectedPath: '/setup/trust-signing/watchlist/alerts',
|
|
expectQuery: {
|
|
alertId: 'alert-001',
|
|
returnTo: '/mission-control/alerts',
|
|
scope: 'tenant',
|
|
tab: 'alerts',
|
|
},
|
|
},
|
|
{
|
|
action: 'link:Waivers expiring',
|
|
name: '2 waivers expiring within 24h',
|
|
expectedPath: '/security/disposition',
|
|
},
|
|
{
|
|
action: 'link:Feed freshness degraded',
|
|
name: 'Feed freshness degraded for advisory ingest',
|
|
expectedPath: '/ops/operations/data-integrity',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
route: '/mission-control/activity',
|
|
actions: [
|
|
{ action: 'link:Open Runs', name: 'Open Runs', expectedPath: '/releases/runs' },
|
|
{ action: 'link:Open Capsules', name: 'Open Capsules', expectedPath: '/evidence/capsules' },
|
|
{ action: 'link:Open Audit Log', name: 'Open Audit Log', expectedPath: '/evidence/audit-log' },
|
|
],
|
|
},
|
|
];
|
|
|
|
for (const group of actionGroups) {
|
|
const actions = [];
|
|
for (const action of group.actions) {
|
|
actions.push(await runAction(page, group.route, action));
|
|
}
|
|
|
|
summary.results.push({
|
|
route: group.route,
|
|
actions,
|
|
});
|
|
await persistSummary(summary);
|
|
}
|
|
|
|
await context.close();
|
|
await browser.close();
|
|
|
|
const failedActionCount = summary.results
|
|
.flatMap((entry) => entry.actions)
|
|
.filter((entry) => !entry.ok).length;
|
|
const runtimeIssueCount = runtime.consoleErrors.length
|
|
+ runtime.pageErrors.length
|
|
+ runtime.requestFailures.length
|
|
+ runtime.responseErrors.length;
|
|
|
|
summary.failedActionCount = failedActionCount;
|
|
summary.runtimeIssueCount = runtimeIssueCount;
|
|
await persistSummary(summary);
|
|
|
|
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
|
|
if (failedActionCount > 0 || runtimeIssueCount > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`[live-mission-control-action-sweep] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exit(1);
|
|
});
|