#!/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-uncovered-surface-action-sweep.json'); const authStatePath = path.join(outputDir, 'live-uncovered-surface-action-sweep.state.json'); const authReportPath = path.join(outputDir, 'live-uncovered-surface-action-sweep.auth.json'); const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; const linkChecks = [ ['/releases/overview', 'Release Versions', '/releases/versions'], ['/releases/overview', 'Release Runs', '/releases/runs'], ['/releases/overview', 'Approvals Queue', '/releases/approvals'], ['/releases/overview', 'Hotfixes', '/releases/hotfixes'], ['/releases/overview', 'Promotions', '/releases/promotions'], ['/releases/overview', 'Deployment History', '/releases/deployments'], ['/releases/runs', 'Timeline', '/releases/runs?view=timeline'], ['/releases/runs', 'Table', '/releases/runs?view=table'], ['/releases/runs', 'Correlations', '/releases/runs?view=correlations'], ['/releases/approvals', 'Pending', '/releases/approvals?tab=pending'], ['/releases/approvals', 'Approved', '/releases/approvals?tab=approved'], ['/releases/approvals', 'Rejected', '/releases/approvals?tab=rejected'], ['/releases/approvals', 'Expiring', '/releases/approvals?tab=expiring'], ['/releases/approvals', 'My Team', '/releases/approvals?tab=my-team'], ['/releases/environments', 'Open Environment', '/setup/topology/environments/stage/posture'], ['/releases/environments', 'Open Targets', '/setup/topology/targets'], ['/releases/environments', 'Open Agents', '/setup/topology/agents'], ['/releases/environments', 'Open Runs', '/releases/runs'], ['/releases/investigation/deploy-diff', 'Open Deployments', '/releases/deployments'], ['/releases/investigation/deploy-diff', 'Open Releases Overview', '/releases/overview'], ['/releases/investigation/change-trace', 'Open Deployments', '/releases/deployments'], ['/security/posture', 'Open triage', '/security/triage'], ['/security/posture', 'Disposition', '/security/disposition'], ['/security/posture', 'Configure sources', '/ops/integrations/advisory-vex-sources'], ['/security/posture', 'Open reachability coverage board', '/security/reachability'], ['/security/advisories-vex', 'Providers', '/security/advisories-vex?tab=providers'], ['/security/advisories-vex', 'VEX Library', '/security/advisories-vex?tab=vex-library'], ['/security/advisories-vex', 'Issuer Trust', '/security/advisories-vex?tab=issuer-trust'], ['/security/disposition', 'Conflicts', '/security/disposition?tab=conflicts'], ['/security/supply-chain-data', 'SBOM Graph', '/security/supply-chain-data/graph'], ['/security/supply-chain-data', 'Reachability', '/security/reachability'], ['/evidence/overview', 'Audit Log', '/evidence/audit-log'], ['/evidence/overview', 'Export Center', '/evidence/exports'], ['/evidence/overview', 'Replay & Verify', '/evidence/verify-replay'], ['/evidence/audit-log', 'View All Events', '/evidence/audit-log/events'], ['/evidence/audit-log', 'Export', '/evidence/exports'], ['/evidence/audit-log', /Policy Audit/i, '/evidence/audit-log/policy'], ['/ops/operations/health-slo', 'View Full Timeline', '/ops/operations/health-slo/incidents'], ['/ops/operations/feeds-airgap', 'Configure Sources', '/ops/integrations/advisory-vex-sources'], ['/ops/operations/feeds-airgap', 'Open Offline Bundles', '/ops/operations/offline-kit/bundles'], ['/ops/operations/feeds-airgap', 'Version Locks', '/ops/operations/feeds-airgap?tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d&tab=version-locks'], ['/ops/operations/data-integrity', 'Feeds Freshness WARN Impact: BLOCKING NVD feed stale by 3h 12m', '/ops/operations/data-integrity/feeds-freshness'], ['/ops/operations/data-integrity', 'Hotfix 1.2.4', '/releases/approvals?releaseId=rel-hotfix-124'], ['/ops/operations/jobengine', 'Scheduler Runs', '/ops/operations/scheduler/runs'], ['/ops/operations/jobengine', /Execution Quotas/i, '/ops/operations/jobengine/quotas'], ['/ops/operations/offline-kit', 'Bundles', '/ops/operations/offline-kit/bundles'], ['/ops/operations/offline-kit', 'JWKS', '/ops/operations/offline-kit/jwks'], ]; const buttonChecks = [ ['/releases/versions', 'Create Release Version', '/releases/versions/new'], ['/releases/versions', 'Create Hotfix Run', '/releases/versions/new'], ['/releases/versions', 'Search'], ['/releases/versions', 'Clear'], ['/releases/investigation/timeline', 'Export'], ['/releases/investigation/change-trace', 'Export'], ['/security/sbom-lake', 'Refresh'], ['/security/sbom-lake', 'Clear'], ['/security/reachability', 'Witnesses'], ['/security/reachability', /PoE|Proof of Exposure/i], ['/evidence/capsules', 'Search'], ['/evidence/threads', 'Search'], ['/evidence/proofs', 'Search'], ['/ops/operations/system-health', 'Services'], ['/ops/operations/system-health', 'Incidents'], ['/ops/operations/system-health', 'Quick Diagnostics'], ['/ops/operations/scheduler', 'Manage Schedules', '/ops/operations/scheduler/schedules'], ['/ops/operations/scheduler', 'Worker Fleet', '/ops/operations/scheduler/workers'], ['/ops/operations/doctor', 'Quick Check'], ['/ops/operations/signals', 'Refresh'], ['/ops/operations/packs', 'Refresh'], ['/ops/operations/status', 'Refresh'], ]; function buildUrl(route) { const separator = route.includes('?') ? '&' : '?'; return `${baseUrl}${route}${separator}${scopeQuery}`; } async function settle(page) { await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); await page.waitForTimeout(1_500); } async function captureSnapshot(page, label) { const alerts = await page .locator('[role="alert"], .mat-mdc-snack-bar-container, .toast, .notification, .error-banner, .warning-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().catch(() => ''), heading: await page.locator('h1, h2, [data-testid="page-title"], .page-title').first().innerText().catch(() => ''), alerts, }; } async function navigate(page, route) { await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 }); await settle(page); } async function findLink(page, name, timeoutMs = 10_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const link = page.getByRole('link', { name }).first(); if (await link.count()) { return link; } await page.waitForTimeout(250); } return null; } function matchesLocatorName(name, text) { if (!text) { return false; } const normalizedText = text.trim(); if (!normalizedText) { return false; } if (name instanceof RegExp) { return name.test(normalizedText); } return normalizedText.includes(name); } async function findScopedLink(page, scopeSelector, name) { const scope = page.locator(scopeSelector).first(); if (!(await scope.count())) { return null; } const descendantLink = scope.getByRole('link', { name }).first(); if (await descendantLink.count()) { return descendantLink; } const [tagName, href, text] = await Promise.all([ scope.evaluate((element) => element.tagName.toLowerCase()).catch(() => ''), scope.getAttribute('href').catch(() => null), scope.textContent().catch(() => ''), ]); if (tagName === 'a' && href && matchesLocatorName(name, text ?? '')) { return scope; } return null; } async function findLinkForRoute(page, route, name, timeoutMs = 10_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { let locator = null; if (route === '/releases/environments' && name === 'Open Environment') { locator = page.locator('.actions').getByRole('link', { name }).first(); } else if (route === '/security/posture' && name === 'Configure sources') { locator = page.locator('#main-content .panel').getByRole('link', { name }).first(); } else if (route === '/ops/operations/jobengine' && name instanceof RegExp && String(name) === String(/Execution Quotas/i)) { locator = await findScopedLink(page, '[data-testid="jobengine-quotas-card"]', name); } else if (route === '/ops/operations/offline-kit' && name === 'Bundles') { locator = page.locator('.tab-nav').getByRole('link', { name }).first(); } else { locator = page.getByRole('link', { name }).first(); } if (await locator.count()) { return locator; } await page.waitForTimeout(250); } return null; } async function findButton(page, route, name, timeoutMs = 10_000) { const deadline = Date.now() + timeoutMs; const scopes = []; if (route === '/releases/versions' && name === 'Create Hotfix Run') { scopes.push(page.locator('#main-content .header-actions').first()); } scopes.push(page.locator('#main-content').first(), page.locator('body')); while (Date.now() < deadline) { for (const scope of scopes) { for (const role of ['button', 'tab']) { const button = scope.getByRole(role, { name }).first(); if (await button.count()) { return button; } } } await page.waitForTimeout(250); } return null; } async function waitForPath(page, expectedPath, timeoutMs = 10_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (normalizeUrl(page.url()).includes(expectedPath)) { return true; } await page.waitForTimeout(250); } return false; } async function findButtonLegacy(page, name, timeoutMs = 10_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { for (const role of ['button', 'tab']) { const button = page.getByRole(role, { name }).first(); if (await button.count()) { return button; } } await page.waitForTimeout(250); } return null; } function normalizeUrl(url) { return decodeURIComponent(url); } function shouldIgnoreConsoleError(message) { return message === 'Failed to load resource: the server responded with a status of 401 ()'; } function shouldIgnoreRequestFailure(request) { return request.failure === 'net::ERR_ABORTED'; } function shouldIgnoreResponseError(response) { return response.status === 401 && /\/doctor\/api\/v1\/doctor\/run\/[^/]+\/stream$/i.test(response.url); } async function runLinkCheck(page, route, name, expectedPath) { const action = `${route} -> link:${name}`; try { await navigate(page, route); const link = await findLinkForRoute(page, route, name); if ( !link && route === '/ops/operations/jobengine' && name instanceof RegExp && String(name) === String(/Execution Quotas/i) ) { const quotasCardText = await page.locator('[data-testid="jobengine-quotas-card"]').textContent().catch(() => ''); const snapshot = await captureSnapshot(page, action); return { action, ok: /access required to manage quotas/i.test(quotasCardText ?? ''), expectedPath, finalUrl: normalizeUrl(snapshot.url), snapshot, reason: 'restricted-card', }; } if (!link) { return { action, ok: false, reason: 'missing-link', snapshot: await captureSnapshot(page, action) }; } await link.click({ timeout: 10_000 }); if (expectedPath) { await waitForPath(page, expectedPath, 10_000); } await settle(page); const snapshot = await captureSnapshot(page, action); const finalUrl = normalizeUrl(snapshot.url); return { action, ok: finalUrl.includes(expectedPath), expectedPath, finalUrl, snapshot, }; } catch (error) { return { action, ok: false, reason: 'exception', error: error instanceof Error ? error.message : String(error), snapshot: await captureSnapshot(page, action), }; } } async function runButtonCheck(page, route, name, expectedPath = null) { const action = `${route} -> button:${name}`; try { await navigate(page, route); const button = await findButton(page, route, name).catch(() => null) ?? await findButtonLegacy(page, name); if (!button) { return { action, ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, action) }; } const disabled = await button.isDisabled().catch(() => false); if (disabled) { const snapshot = await captureSnapshot(page, action); return { action, ok: true, reason: 'disabled-by-design', expectedPath, finalUrl: normalizeUrl(snapshot.url), snapshot, }; } await button.click({ timeout: 10_000 }); if (expectedPath) { await waitForPath(page, expectedPath, 10_000); } await settle(page); const snapshot = await captureSnapshot(page, action); const finalUrl = normalizeUrl(snapshot.url); const hasRuntimeAlert = snapshot.alerts.some((text) => /(error|failed|unable|timed out|unavailable)/i.test(text)); return { action, ok: expectedPath ? finalUrl.includes(expectedPath) : snapshot.heading.trim().length > 0 && !hasRuntimeAlert, expectedPath, finalUrl, snapshot, }; } catch (error) { return { action, ok: false, reason: 'exception', error: error instanceof Error ? error.message : String(error), snapshot: await captureSnapshot(page, action), }; } } async function main() { await mkdir(outputDir, { recursive: true }); const browser = await chromium.launch({ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false', }); const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath }); const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath }); const page = await context.newPage(); const results = []; const runtime = { consoleErrors: [], pageErrors: [], requestFailures: [], responseErrors: [], }; page.on('console', (message) => { if (message.type() === 'error' && !shouldIgnoreConsoleError(message.text())) { runtime.consoleErrors.push(message.text()); } }); page.on('pageerror', (error) => runtime.pageErrors.push(error.message)); page.on('requestfailed', (request) => { const failure = request.failure()?.errorText ?? 'unknown'; if (!shouldIgnoreRequestFailure({ url: request.url(), failure })) { runtime.requestFailures.push({ url: request.url(), failure }); } }); page.on('response', (response) => { if (response.status() >= 400) { const candidate = { url: response.url(), status: response.status() }; if (!shouldIgnoreResponseError(candidate)) { runtime.responseErrors.push(candidate); } } }); try { for (const [route, name, expectedPath] of linkChecks) { process.stdout.write(`[live-uncovered-surface-action-sweep] START ${route} -> link:${name}\n`); const result = await runLinkCheck(page, route, name, expectedPath); process.stdout.write( `[live-uncovered-surface-action-sweep] DONE ${route} -> link:${name} ok=${result.ok}\n`, ); results.push(result); } for (const [route, name, expectedPath] of buttonChecks) { process.stdout.write(`[live-uncovered-surface-action-sweep] START ${route} -> button:${name}\n`); const result = await runButtonCheck(page, route, name, expectedPath); process.stdout.write( `[live-uncovered-surface-action-sweep] DONE ${route} -> button:${name} ok=${result.ok}\n`, ); results.push(result); } } finally { await page.close().catch(() => {}); await context.close().catch(() => {}); await browser.close().catch(() => {}); } const failedActions = results.filter((result) => !result.ok); const report = { generatedAtUtc: new Date().toISOString(), baseUrl, actionCount: results.length, passedActionCount: results.length - failedActions.length, failedActionCount: failedActions.length, failedActions, results, runtime, runtimeIssueCount: runtime.consoleErrors.length + runtime.pageErrors.length + runtime.requestFailures.length + runtime.responseErrors.length, }; await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); if (failedActions.length > 0 || report.runtimeIssueCount > 0) { process.exitCode = 1; } } await main();