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