- Introduce resolveApiBaseUrl() helper for consistent URL construction - Fix evidence-pack queries to use public /v1/evidence-packs with runId param - Resolve notify tenant from active context instead of hard-coded override - Gate console run stream on concrete run ID (remove synthetic 'last' token) - Remove unnecessary installed-pack probe from dashboard load - Expand canonical route inventory with investigation and registry surfaces Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
423 lines
12 KiB
JavaScript
423 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
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 outputDirectory = path.join(webRoot, 'output', 'playwright');
|
|
|
|
const BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
|
const STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
|
|
const REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
|
|
const RESULT_PATH = path.join(outputDirectory, 'live-frontdoor-canonical-route-sweep.json');
|
|
|
|
const scopedPrefixes = ['/mission-control', '/releases', '/security', '/evidence', '/ops'];
|
|
const defaultScopeEntries = [
|
|
['tenant', 'demo-prod'],
|
|
['regions', 'us-east'],
|
|
['environments', 'stage'],
|
|
['timeWindow', '7d'],
|
|
];
|
|
|
|
const canonicalRoutes = [
|
|
'/mission-control/board',
|
|
'/mission-control/alerts',
|
|
'/mission-control/activity',
|
|
'/releases',
|
|
'/releases/overview',
|
|
'/releases/versions',
|
|
'/releases/versions/new',
|
|
'/releases/runs',
|
|
'/releases/approvals',
|
|
'/releases/promotion-queue',
|
|
'/releases/hotfixes',
|
|
'/releases/hotfixes/new',
|
|
'/releases/environments',
|
|
'/releases/deployments',
|
|
'/releases/investigation/timeline',
|
|
'/releases/investigation/deploy-diff',
|
|
'/releases/investigation/change-trace',
|
|
'/security',
|
|
'/security/posture',
|
|
'/security/triage',
|
|
'/security/advisories-vex',
|
|
'/security/disposition',
|
|
'/security/supply-chain-data',
|
|
'/security/supply-chain-data/graph',
|
|
'/security/sbom-lake',
|
|
'/security/reachability',
|
|
'/security/reports',
|
|
'/evidence',
|
|
'/evidence/overview',
|
|
'/evidence/capsules',
|
|
'/evidence/threads',
|
|
'/evidence/verify-replay',
|
|
'/evidence/proofs',
|
|
'/evidence/exports',
|
|
'/evidence/audit-log',
|
|
'/ops',
|
|
'/ops/operations',
|
|
'/ops/operations/jobs-queues',
|
|
'/ops/operations/feeds-airgap',
|
|
'/ops/operations/data-integrity',
|
|
'/ops/operations/system-health',
|
|
'/ops/operations/health-slo',
|
|
'/ops/operations/jobengine',
|
|
'/ops/operations/scheduler',
|
|
'/ops/operations/quotas',
|
|
'/ops/operations/offline-kit',
|
|
'/ops/operations/dead-letter',
|
|
'/ops/operations/aoc',
|
|
'/ops/operations/doctor',
|
|
'/ops/operations/signals',
|
|
'/ops/operations/packs',
|
|
'/ops/operations/ai-runs',
|
|
'/ops/operations/notifications',
|
|
'/ops/operations/status',
|
|
'/ops/integrations',
|
|
'/ops/integrations/onboarding',
|
|
'/ops/integrations/registries',
|
|
'/ops/integrations/scm',
|
|
'/ops/integrations/ci',
|
|
'/ops/integrations/runtime-hosts',
|
|
'/ops/integrations/advisory-vex-sources',
|
|
'/ops/integrations/secrets',
|
|
'/ops/integrations/notifications',
|
|
'/ops/integrations/sbom-sources',
|
|
'/ops/integrations/activity',
|
|
'/ops/integrations/registry-admin',
|
|
'/ops/policy',
|
|
'/ops/policy/overview',
|
|
'/ops/policy/baselines',
|
|
'/ops/policy/gates',
|
|
'/ops/policy/simulation',
|
|
'/ops/policy/waivers',
|
|
'/ops/policy/risk-budget',
|
|
'/ops/policy/trust-weights',
|
|
'/ops/policy/staleness',
|
|
'/ops/policy/sealed-mode',
|
|
'/ops/policy/profiles',
|
|
'/ops/policy/validator',
|
|
'/ops/policy/audit',
|
|
'/ops/platform-setup',
|
|
'/ops/platform-setup/regions-environments',
|
|
'/ops/platform-setup/promotion-paths',
|
|
'/ops/platform-setup/workflows-gates',
|
|
'/ops/platform-setup/release-templates',
|
|
'/ops/platform-setup/policy-bindings',
|
|
'/ops/platform-setup/gate-profiles',
|
|
'/ops/platform-setup/defaults-guardrails',
|
|
'/ops/platform-setup/trust-signing',
|
|
'/setup',
|
|
'/setup/integrations',
|
|
'/setup/integrations/advisory-vex-sources',
|
|
'/setup/integrations/secrets',
|
|
'/setup/identity-access',
|
|
'/setup/tenant-branding',
|
|
'/setup/notifications',
|
|
'/setup/usage',
|
|
'/setup/system',
|
|
'/setup/trust-signing',
|
|
'/setup/topology',
|
|
'/setup/topology/overview',
|
|
'/setup/topology/map',
|
|
'/setup/topology/regions',
|
|
'/setup/topology/environments',
|
|
'/setup/topology/targets',
|
|
'/setup/topology/hosts',
|
|
'/setup/topology/agents',
|
|
'/setup/topology/connectivity',
|
|
'/setup/topology/runtime-drift',
|
|
'/setup/topology/promotion-graph',
|
|
'/setup/topology/workflows',
|
|
'/setup/topology/gate-profiles',
|
|
];
|
|
|
|
const strictRouteExpectations = {
|
|
'/security/advisories-vex': {
|
|
title: /Advisories/i,
|
|
texts: ['Security / Advisories & VEX', 'Providers'],
|
|
},
|
|
'/security/sbom-lake': {
|
|
title: /SBOM Lake/i,
|
|
texts: ['SBOM Lake', 'Attestation Coverage Metrics'],
|
|
},
|
|
'/security/reachability': {
|
|
title: /Reachability/i,
|
|
texts: ['Reachability', 'Proof of Exposure'],
|
|
},
|
|
'/setup/trust-signing': {
|
|
title: /Trust/i,
|
|
texts: ['Trust Management'],
|
|
},
|
|
'/setup/integrations': {
|
|
title: /Integrations/i,
|
|
texts: ['Integrations', 'External system connectors'],
|
|
},
|
|
'/setup/integrations/advisory-vex-sources': {
|
|
title: /Advisory & VEX Sources/i,
|
|
texts: ['Integrations', 'FeedMirror Integrations'],
|
|
},
|
|
'/setup/integrations/secrets': {
|
|
title: /Secrets/i,
|
|
texts: ['Integrations', 'RepoSource Integrations'],
|
|
},
|
|
'/ops/policy': {
|
|
title: /Policy/i,
|
|
texts: ['Policy Decisioning Studio', 'One operator shell for policy, VEX, and release gates'],
|
|
},
|
|
'/ops/policy/overview': {
|
|
title: /Policy/i,
|
|
texts: ['Policy Decisioning Studio', 'One operator shell for policy, VEX, and release gates'],
|
|
},
|
|
'/ops/policy/risk-budget': {
|
|
title: /Policy/i,
|
|
texts: ['Policy Decisioning Studio', 'Risk Budget Overview'],
|
|
},
|
|
};
|
|
|
|
const allowedFinalPaths = {
|
|
'/releases': ['/releases/deployments'],
|
|
'/releases/promotion-queue': ['/releases/promotions'],
|
|
'/ops/policy': ['/ops/policy/overview'],
|
|
'/ops/policy/audit': ['/ops/policy/audit/policy'],
|
|
'/ops/platform-setup/trust-signing': ['/setup/trust-signing'],
|
|
'/setup/topology': ['/setup/topology/overview'],
|
|
};
|
|
|
|
function buildRouteUrl(routePath) {
|
|
const url = new URL(routePath, BASE_URL);
|
|
if (scopedPrefixes.some((prefix) => routePath.startsWith(prefix))) {
|
|
for (const [key, value] of defaultScopeEntries) {
|
|
if (!url.searchParams.has(key)) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
return url.toString();
|
|
}
|
|
|
|
function trimText(value, maxLength = 300) {
|
|
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
|
|
}
|
|
|
|
function shouldIgnoreUrl(url) {
|
|
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url) || url.startsWith('data:');
|
|
}
|
|
|
|
async function collectHeadings(page) {
|
|
return page.locator('h1, main h1, main h2, h2').evaluateAll((elements) =>
|
|
elements
|
|
.map((element) => (element.textContent || '').replace(/\s+/g, ' ').trim())
|
|
.filter((text, index, values) => text.length > 0 && values.indexOf(text) === index),
|
|
).catch(() => []);
|
|
}
|
|
|
|
async function collectVisibleProblemTexts(page) {
|
|
return page.locator([
|
|
'[role="alert"]',
|
|
'.alert',
|
|
'.banner',
|
|
'.status-banner',
|
|
'.degraded-banner',
|
|
'.warning-banner',
|
|
'.error-banner',
|
|
'.empty-state',
|
|
'.error-state',
|
|
].join(', ')).evaluateAll((elements) =>
|
|
elements
|
|
.map((element) => (element.textContent || '').replace(/\s+/g, ' ').trim())
|
|
.filter((text) =>
|
|
text.length > 0 && /(failed|unable|error|warning|degraded|unavailable|timed out|timeout|no results)/i.test(text),
|
|
),
|
|
).catch(() => []);
|
|
}
|
|
|
|
async function collectVisibleActions(page) {
|
|
return page.locator('main a[href], main button').evaluateAll((elements) =>
|
|
elements
|
|
.map((element) => {
|
|
const tagName = element.tagName.toLowerCase();
|
|
const text = (element.textContent || '').replace(/\s+/g, ' ').trim();
|
|
const href = tagName === 'a' ? element.getAttribute('href') || '' : '';
|
|
return {
|
|
tagName,
|
|
text,
|
|
href,
|
|
};
|
|
})
|
|
.filter((entry) => entry.text.length > 0 || entry.href.length > 0)
|
|
.slice(0, 20),
|
|
).catch(() => []);
|
|
}
|
|
|
|
function collectExpectationFailures(routePath, title, headings, bodyText) {
|
|
const expectation = strictRouteExpectations[routePath];
|
|
if (!expectation) {
|
|
return [];
|
|
}
|
|
|
|
const failures = [];
|
|
if (!expectation.title.test(title)) {
|
|
failures.push(`title mismatch: expected ${expectation.title}`);
|
|
}
|
|
|
|
for (const text of expectation.texts) {
|
|
const present = headings.some((heading) => heading.includes(text)) || bodyText.includes(text);
|
|
if (!present) {
|
|
failures.push(`missing text: ${text}`);
|
|
}
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function finalPathMatchesRoute(routePath, finalUrl) {
|
|
const finalPath = new URL(finalUrl).pathname;
|
|
if (finalPath === routePath) {
|
|
return true;
|
|
}
|
|
|
|
return allowedFinalPaths[routePath]?.includes(finalPath) ?? false;
|
|
}
|
|
|
|
async function inspectRoute(context, routePath) {
|
|
const page = await context.newPage();
|
|
const consoleErrors = [];
|
|
const requestFailures = [];
|
|
const responseErrors = [];
|
|
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
consoleErrors.push(trimText(message.text(), 500));
|
|
}
|
|
});
|
|
|
|
page.on('requestfailed', (request) => {
|
|
const url = request.url();
|
|
if (shouldIgnoreUrl(url)) {
|
|
return;
|
|
}
|
|
|
|
requestFailures.push({
|
|
method: request.method(),
|
|
url,
|
|
error: request.failure()?.errorText ?? 'unknown',
|
|
});
|
|
});
|
|
|
|
page.on('response', (response) => {
|
|
const url = response.url();
|
|
if (shouldIgnoreUrl(url)) {
|
|
return;
|
|
}
|
|
|
|
if (response.status() >= 400) {
|
|
responseErrors.push({
|
|
status: response.status(),
|
|
method: response.request().method(),
|
|
url,
|
|
});
|
|
}
|
|
});
|
|
|
|
const targetUrl = buildRouteUrl(routePath);
|
|
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
|
|
await page.waitForTimeout(1_500);
|
|
|
|
const finalUrl = page.url();
|
|
const title = await page.title().catch(() => '');
|
|
const headings = await collectHeadings(page);
|
|
const bodyText = trimText(await page.locator('body').innerText().catch(() => ''), 1_200);
|
|
const problemTexts = await collectVisibleProblemTexts(page);
|
|
const visibleActions = await collectVisibleActions(page);
|
|
|
|
const finalPathMatches = finalPathMatchesRoute(routePath, finalUrl);
|
|
const expectationFailures = collectExpectationFailures(routePath, title, headings, bodyText);
|
|
|
|
const record = {
|
|
routePath,
|
|
targetUrl,
|
|
finalUrl,
|
|
title,
|
|
headings,
|
|
problemTexts,
|
|
visibleActions,
|
|
consoleErrors,
|
|
requestFailures,
|
|
responseErrors,
|
|
finalPathMatches,
|
|
expectationFailures,
|
|
passed: finalPathMatches
|
|
&& expectationFailures.length === 0
|
|
&& consoleErrors.length === 0
|
|
&& requestFailures.length === 0
|
|
&& responseErrors.length === 0
|
|
&& problemTexts.length === 0,
|
|
};
|
|
|
|
await page.close();
|
|
return record;
|
|
}
|
|
|
|
async function main() {
|
|
mkdirSync(outputDirectory, { recursive: true });
|
|
|
|
await authenticateFrontdoor({
|
|
baseUrl: BASE_URL,
|
|
statePath: STATE_PATH,
|
|
reportPath: REPORT_PATH,
|
|
headless: true,
|
|
});
|
|
|
|
const authReport = JSON.parse(readFileSync(REPORT_PATH, 'utf8'));
|
|
const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] });
|
|
|
|
try {
|
|
const context = await createAuthenticatedContext(browser, authReport, { statePath: STATE_PATH });
|
|
const routes = [];
|
|
|
|
for (const routePath of canonicalRoutes) {
|
|
const record = await inspectRoute(context, routePath);
|
|
routes.push(record);
|
|
const status = record.passed ? 'PASS' : 'FAIL';
|
|
process.stdout.write(`[live-frontdoor-canonical-route-sweep] ${status} ${routePath} -> ${record.finalUrl}\n`);
|
|
}
|
|
|
|
await context.close();
|
|
|
|
const summary = {
|
|
checkedAtUtc: new Date().toISOString(),
|
|
baseUrl: BASE_URL,
|
|
totalRoutes: routes.length,
|
|
passedRoutes: routes.filter((route) => route.passed).length,
|
|
failedRoutes: routes.filter((route) => !route.passed).map((route) => route.routePath),
|
|
routes,
|
|
};
|
|
|
|
writeFileSync(RESULT_PATH, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
|
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
|
|
if (summary.failedRoutes.length > 0) {
|
|
process.exitCode = 1;
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`[live-frontdoor-canonical-route-sweep] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exit(1);
|
|
});
|