Files
git.stella-ops.org/src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs

532 lines
16 KiB
JavaScript

#!/usr/bin/env node
import { mkdirSync, 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 baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const statePath = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
const outputPath = path.join(outputDirectory, 'live-frontdoor-changed-surfaces.json');
const surfaceConfigs = [
{
key: 'mission-board',
path: '/mission-control/board?tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d',
heading: /dashboard|mission board|mission control/i,
searchQuery: 'database connectivity',
actions: [
{
key: 'sbom-lake',
selector: '#main-content a[href*="/security/sbom-lake"], #main-content a:has-text("View SBOM")',
expectedUrlPattern: '/security/sbom-lake',
expectedTextPattern: /sbom lake/i,
},
{
key: 'reachability',
selector: '#main-content a[href*="/security/reachability"], #main-content a:has-text("View reachability")',
expectedUrlPattern: '/security/reachability',
expectedTextPattern: /reachability/i,
},
],
},
{
key: 'advisories-vex',
path: '/security/advisories-vex',
heading: /advisories|vex/i,
searchQuery: 'what evidence conflicts with this vex',
},
{
key: 'policy-overview',
path: '/ops/policy',
heading: /policy/i,
searchQuery: 'production deny rules',
},
{
key: 'evidence-threads',
path: '/evidence/threads',
heading: /evidence threads/i,
searchQuery: 'artifact digest',
actions: [
{
key: 'search-empty-result',
fillSelector: 'main input[placeholder*="pkg:oci"], main input[matinput]',
fillValue: 'pkg:npm/example@1.0.0',
submitSelector: 'main button:has-text("Search")',
expectedUrlPattern: '/evidence/threads',
expectedTextPattern: /no evidence threads matched this package url/i,
},
],
},
{
key: 'evidence-thread-detail-missing',
path: '/evidence/threads/missing-demo-canonical?purl=pkg%3Anpm%2Fexample%401.0.0',
heading: /missing-demo-canonical/i,
searchQuery: 'artifact digest',
allowedConsolePatterns: [
/Failed to load resource: the server responded with a status of 404 \(\)/i,
],
allowedResponsePatterns: [
/\/api\/v1\/evidence\/thread\/missing-demo-canonical$/,
],
actions: [
{
key: 'back-to-search',
selector: 'main button:has-text("Back to Search")',
expectedUrlPattern: '/evidence/threads',
expectedTextPattern: /evidence threads/i,
requiredUrlFragments: ['tenant=', 'regions='],
},
],
},
{
key: 'release-investigation-timeline',
path: '/releases/investigation/timeline',
heading: /timeline/i,
searchQuery: 'release correlation timeline',
},
{
key: 'release-investigation-deploy-diff',
path: '/releases/investigation/deploy-diff',
heading: /deploy diff|deployment diff|no comparison selected/i,
searchQuery: 'deployment diff',
actions: [
{
key: 'open-deployments',
selector: 'main a:has-text("Open Deployments")',
expectedUrlPattern: '/releases/deployments',
expectedTextPattern: /deployments/i,
requiredUrlFragments: ['tenant=demo-prod'],
},
{
key: 'open-releases-overview',
selector: 'main a:has-text("Open Releases Overview")',
expectedUrlPattern: '/releases/overview',
expectedTextPattern: /overview|releases/i,
requiredUrlFragments: ['tenant=demo-prod'],
},
],
},
{
key: 'release-investigation-change-trace',
path: '/releases/investigation/change-trace',
heading: /change trace/i,
searchQuery: 'change trace',
actions: [
{
key: 'open-deployments',
selector: 'main a:has-text("Open Deployments")',
expectedUrlPattern: '/releases/deployments',
expectedTextPattern: /deployments/i,
requiredUrlFragments: ['tenant=demo-prod'],
},
],
},
{
key: 'registry-admin',
path: '/ops/integrations/registry-admin',
heading: /registry token service/i,
searchQuery: 'registry token plan',
actions: [
{
key: 'audit-tab',
selector: 'nav[role="tablist"] a[href*="/registry-admin/audit"], nav[role="tablist"] a:has-text("Audit Log")',
expectedUrlPattern: '/registry-admin/audit',
expectedTextPattern: /audit/i,
requiredUrlFragments: ['tenant=', 'regions='],
},
],
},
];
function shouldIgnoreUrl(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)
|| url.startsWith('data:');
}
function trimText(value, maxLength = 400) {
const normalized = value.replace(/\s+/g, ' ').trim();
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
}
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 collectBodyText(page, maxLength = 1_200) {
return trimText(await page.locator('body').innerText().catch(() => ''), maxLength);
}
function matchesPattern(pattern, headings, bodyText = '') {
if (!pattern) {
return true;
}
return headings.some((heading) => pattern.test(heading)) || pattern.test(bodyText);
}
function firstMatchingHeading(pattern, headings) {
if (!pattern) {
return headings[0] ?? '';
}
return headings.find((heading) => pattern.test(heading)) ?? headings[0] ?? '';
}
function matchesAnyPattern(patterns, value) {
if (!Array.isArray(patterns) || patterns.length === 0) {
return false;
}
return patterns.some((pattern) => pattern.test(value));
}
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 runGlobalSearch(page, query, record) {
const searchInput = page.locator('app-global-search input[type="text"]').first();
const available = await searchInput.isVisible().catch(() => false);
if (!available) {
return { available: false };
}
let queryResponse = null;
let suggestionResponse = null;
const onResponse = async (response) => {
const url = response.url();
if (url.includes('/api/v1/search/query')) {
queryResponse = {
status: response.status(),
url,
};
}
if (url.includes('/api/v1/search/suggestions/evaluate')) {
suggestionResponse = {
status: response.status(),
url,
};
}
};
page.on('response', onResponse);
await searchInput.click();
await searchInput.fill(query);
await page.waitForTimeout(1_800);
await page.locator('.search__results').waitFor({ state: 'visible', timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2_000);
page.off('response', onResponse);
const resultsVisible = await page.locator('.search__results').isVisible().catch(() => false);
const resultsText = resultsVisible
? trimText(await page.locator('.search__results').innerText().catch(() => ''))
: '';
const suggestionTexts = (await page.locator('.search__suggestions .search__chip').allTextContents().catch(() => []))
.map((value) => trimText(value, 140))
.filter((value) => value.length > 0);
record.searchRequests = {
queryResponse,
suggestionResponse,
};
return {
available: true,
query,
inputValue: await searchInput.inputValue().catch(() => ''),
resultsVisible,
suggestionCount: suggestionTexts.length,
suggestions: suggestionTexts.slice(0, 6),
resultsText,
};
}
async function inspectSurface(context, surface) {
const page = await context.newPage();
const record = {
key: surface.key,
url: `${baseUrl}${surface.path}`,
finalUrl: '',
title: '',
headings: [],
headingMatched: false,
headingText: '',
problemTexts: [],
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
searchRequests: null,
search: null,
actions: [],
};
page.on('console', (message) => {
if (message.type() === 'error') {
const text = message.text();
if (matchesAnyPattern(surface.allowedConsolePatterns, text)) {
return;
}
record.consoleErrors.push(text);
}
});
page.on('pageerror', (error) => {
record.pageErrors.push(error.message);
});
page.on('requestfailed', (request) => {
const url = request.url();
if (shouldIgnoreUrl(url)) {
return;
}
record.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) {
if (matchesAnyPattern(surface.allowedResponsePatterns, url)) {
return;
}
record.responseErrors.push({
status: response.status(),
method: response.request().method(),
url,
});
}
});
await page.goto(record.url, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForTimeout(4_000);
record.finalUrl = page.url();
record.title = await page.title();
const headings = await collectHeadings(page);
record.headings = headings.slice(0, 8);
record.headingText = firstMatchingHeading(surface.heading, headings);
record.headingMatched = matchesPattern(surface.heading, headings);
record.problemTexts = await collectVisibleProblemTexts(page);
record.search = await runGlobalSearch(page, surface.searchQuery, record);
const screenshotPath = path.join(outputDirectory, `${surface.key}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => {});
record.screenshotPath = screenshotPath;
await page.close();
return record;
}
async function verifySurfaceActions(context, surface) {
if (!Array.isArray(surface.actions) || surface.actions.length === 0) {
return [];
}
const results = [];
for (const action of surface.actions) {
const page = await context.newPage();
try {
await page.goto(`${baseUrl}${surface.path}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForTimeout(4_000);
if (action.fillSelector) {
const fillTarget = page.locator(action.fillSelector).first();
const fillVisible = await fillTarget.isVisible().catch(() => false);
if (!fillVisible) {
results.push({
key: action.key,
ok: false,
reason: 'fill_target_not_visible',
finalUrl: page.url(),
});
await page.close();
continue;
}
await fillTarget.fill(action.fillValue ?? '');
}
const triggerSelector = action.submitSelector ?? action.selector;
const trigger = page.locator(triggerSelector).first();
const visible = await trigger.isVisible().catch(() => false);
if (!visible) {
results.push({
key: action.key,
ok: false,
reason: 'link_not_visible',
finalUrl: page.url(),
});
await page.close();
continue;
}
await trigger.click();
await page.waitForTimeout(4_000);
const headings = await collectHeadings(page);
const bodyText = await collectBodyText(page);
const headingText = firstMatchingHeading(action.expectedTextPattern, headings);
const finalUrl = page.url();
const hasRequiredUrlFragments = (action.requiredUrlFragments ?? []).every((fragment) => finalUrl.includes(fragment));
const ok = finalUrl.includes(action.expectedUrlPattern)
&& hasRequiredUrlFragments
&& matchesPattern(action.expectedTextPattern, headings, bodyText);
results.push({
key: action.key,
ok,
finalUrl,
headingText,
});
} finally {
await page.close().catch(() => {});
}
}
return results;
}
function collectSurfaceIssues(surface, record) {
const issues = [];
if (!record.headingMatched) {
issues.push(`heading-mismatch:${surface.key}:${record.headingText || '<empty>'}`);
}
for (const problemText of record.problemTexts) {
issues.push(`problem-text:${surface.key}:${problemText}`);
}
for (const errorText of record.consoleErrors) {
issues.push(`console:${surface.key}:${errorText}`);
}
for (const errorText of record.pageErrors) {
issues.push(`pageerror:${surface.key}:${errorText}`);
}
for (const failure of record.requestFailures) {
issues.push(`requestfailed:${surface.key}:${failure.method} ${failure.url} ${failure.error}`);
}
for (const failure of record.responseErrors) {
issues.push(`response:${surface.key}:${failure.status} ${failure.method} ${failure.url}`);
}
if (surface.searchQuery) {
if (!record.search?.available) {
issues.push(`search-unavailable:${surface.key}`);
} else if (
!record.search.resultsVisible
&& record.search.suggestionCount === 0
&& (record.search.resultsText || '').length === 0
) {
issues.push(`search-empty:${surface.key}:${surface.searchQuery}`);
}
}
for (const actionResult of record.actions) {
if (!actionResult.ok) {
issues.push(`action-failed:${surface.key}:${actionResult.key}:${actionResult.reason ?? actionResult.finalUrl ?? 'unknown'}`);
}
}
return issues;
}
async function main() {
mkdirSync(outputDirectory, { recursive: true });
const authReport = await authenticateFrontdoor({
statePath,
reportPath: path.join(outputDirectory, 'live-frontdoor-auth-report.json'),
});
const browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, { statePath });
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
surfaces: [],
issues: [],
};
for (const surface of surfaceConfigs) {
const surfaceReport = await inspectSurface(context, surface);
surfaceReport.actions = await verifySurfaceActions(context, surface);
surfaceReport.issues = collectSurfaceIssues(surface, surfaceReport);
surfaceReport.ok = surfaceReport.issues.length === 0;
report.surfaces.push(surfaceReport);
report.issues.push(...surfaceReport.issues);
}
await browser.close();
report.failedSurfaceCount = report.surfaces.filter((surface) => !surface.ok).length;
report.runtimeIssueCount = report.issues.length;
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
if (report.failedSurfaceCount > 0 || report.runtimeIssueCount > 0) {
process.exit(1);
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
main().catch((error) => {
process.stderr.write(`[live-frontdoor-changed-surfaces] ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});
}