469 lines
17 KiB
JavaScript
469 lines
17 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 __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();
|