Add live integrations sweep harness script
This commit is contained in:
330
src/Web/StellaOps.Web/scripts/live-integrations-action-sweep.mjs
Normal file
330
src/Web/StellaOps.Web/scripts/live-integrations-action-sweep.mjs
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
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-integrations-action-sweep.json');
|
||||||
|
const authStatePath = path.join(outputDir, 'live-integrations-action-sweep.state.json');
|
||||||
|
const authReportPath = path.join(outputDir, 'live-integrations-action-sweep.auth.json');
|
||||||
|
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
||||||
|
const STEP_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
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('?') ? '&' : '?';
|
||||||
|
const url = `https://stella-ops.local${route}${separator}${scopeQuery}`;
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||||
|
await settle(page);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(page, route, label, runner) {
|
||||||
|
const startedAtUtc = new Date().toISOString();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
process.stdout.write(`[live-integrations-action-sweep] START ${route} -> ${label}\n`);
|
||||||
|
|
||||||
|
let timeoutHandle = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
runner(),
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
reject(new Error(`Timed out after ${STEP_TIMEOUT_MS}ms.`));
|
||||||
|
}, STEP_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const completed = {
|
||||||
|
action: label,
|
||||||
|
ok: result?.ok ?? true,
|
||||||
|
...result,
|
||||||
|
startedAtUtc,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdout.write(
|
||||||
|
`[live-integrations-action-sweep] DONE ${route} -> ${label} ok=${completed.ok} durationMs=${completed.durationMs}\n`,
|
||||||
|
);
|
||||||
|
return completed;
|
||||||
|
} catch (error) {
|
||||||
|
const failed = {
|
||||||
|
action: label,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
startedAtUtc,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
snapshot: await captureSnapshot(page, `failure:${route}:${label}`),
|
||||||
|
};
|
||||||
|
process.stdout.write(
|
||||||
|
`[live-integrations-action-sweep] FAIL ${route} -> ${label} error=${failed.error} durationMs=${failed.durationMs}\n`,
|
||||||
|
);
|
||||||
|
return failed;
|
||||||
|
} finally {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickLinkVerify(page, route, name, expectedPath) {
|
||||||
|
await navigate(page, route);
|
||||||
|
const candidates = [
|
||||||
|
page.getByRole('link', { name }),
|
||||||
|
page.getByRole('tab', { name }),
|
||||||
|
];
|
||||||
|
|
||||||
|
let locator = null;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if ((await candidate.count()) > 0) {
|
||||||
|
locator = candidate.first();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locator) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'missing-link',
|
||||||
|
snapshot: await captureSnapshot(page, `missing-link:${name}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await locator.click({ timeout: 10_000 });
|
||||||
|
await page.waitForURL((url) => url.pathname.includes(expectedPath), { timeout: 15_000 });
|
||||||
|
await settle(page);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: page.url().includes(expectedPath),
|
||||||
|
expectedPath,
|
||||||
|
snapshot: await captureSnapshot(page, `after-link:${name}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickButtonVerify(page, route, name, expectedPath) {
|
||||||
|
await navigate(page, route);
|
||||||
|
const locator = page.getByRole('button', { name }).first();
|
||||||
|
if ((await locator.count()) === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'missing-button',
|
||||||
|
snapshot: await captureSnapshot(page, `missing-button:${name}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await locator.click({ timeout: 10_000 });
|
||||||
|
await page.waitForURL((url) => url.pathname.includes(expectedPath), { timeout: 15_000 });
|
||||||
|
await settle(page);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: page.url().includes(expectedPath),
|
||||||
|
expectedPath,
|
||||||
|
snapshot: await captureSnapshot(page, `after-button:${name}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const authReport = await authenticateFrontdoor({
|
||||||
|
statePath: authStatePath,
|
||||||
|
reportPath: authReportPath,
|
||||||
|
headless: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--disable-dev-shm-usage'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
consoleErrors: [],
|
||||||
|
pageErrors: [],
|
||||||
|
responseErrors: [],
|
||||||
|
requestFailures: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'error') {
|
||||||
|
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('pageerror', (error) => {
|
||||||
|
runtime.pageErrors.push({ page: page.url(), message: error.message });
|
||||||
|
});
|
||||||
|
page.on('requestfailed', (request) => {
|
||||||
|
const url = request.url();
|
||||||
|
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorText = request.failure()?.errorText ?? 'unknown';
|
||||||
|
if (errorText === 'net::ERR_ABORTED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.requestFailures.push({
|
||||||
|
page: page.url(),
|
||||||
|
method: request.method(),
|
||||||
|
url,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
page.on('response', (response) => {
|
||||||
|
const url = response.url();
|
||||||
|
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status() >= 400) {
|
||||||
|
runtime.responseErrors.push({
|
||||||
|
page: page.url(),
|
||||||
|
method: response.request().method(),
|
||||||
|
status: response.status(),
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const summary = {
|
||||||
|
generatedAtUtc: new Date().toISOString(),
|
||||||
|
results,
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
results.push({
|
||||||
|
route: '/ops/integrations',
|
||||||
|
actions: [
|
||||||
|
await runAction(page, '/ops/integrations', 'link:Registries', () =>
|
||||||
|
clickLinkVerify(page, '/ops/integrations', 'Registries', '/ops/integrations/registries')),
|
||||||
|
await runAction(page, '/ops/integrations', 'link:SCM', () =>
|
||||||
|
clickLinkVerify(page, '/ops/integrations', 'SCM', '/ops/integrations/scm')),
|
||||||
|
await runAction(page, '/ops/integrations', 'link:CI/CD', () =>
|
||||||
|
clickLinkVerify(page, '/ops/integrations', 'CI/CD', '/ops/integrations/ci')),
|
||||||
|
await runAction(page, '/ops/integrations', 'link:Runtimes / Hosts', () =>
|
||||||
|
clickLinkVerify(page, '/ops/integrations', 'Runtimes / Hosts', '/ops/integrations/runtime-hosts')),
|
||||||
|
await runAction(page, '/ops/integrations', 'link:Advisory & VEX', () =>
|
||||||
|
clickLinkVerify(page, '/ops/integrations', 'Advisory & VEX', '/ops/integrations/advisory-vex-sources')),
|
||||||
|
await runAction(page, '/ops/integrations', 'link:Secrets', () =>
|
||||||
|
clickLinkVerify(page, '/ops/integrations', 'Secrets', '/ops/integrations/secrets')),
|
||||||
|
await runAction(page, '/ops/integrations', 'button:+ Add Integration', () =>
|
||||||
|
clickButtonVerify(page, '/ops/integrations', '+ Add Integration', '/ops/integrations/onboarding')),
|
||||||
|
await runAction(page, '/ops/integrations', 'link:View Activity', () =>
|
||||||
|
clickLinkVerify(page, '/ops/integrations', 'View Activity', '/ops/integrations/activity')),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await persistSummary(summary);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
route: '/ops/integrations/registries',
|
||||||
|
actions: [
|
||||||
|
await runAction(page, '/ops/integrations/registries', 'button:+ Add Registry', () =>
|
||||||
|
clickButtonVerify(page, '/ops/integrations/registries', '+ Add Registry', '/ops/integrations/onboarding/registry')),
|
||||||
|
await runAction(page, '/ops/integrations/registries', 'button:Add your first registry', () =>
|
||||||
|
clickButtonVerify(page, '/ops/integrations/registries', 'Add your first registry', '/ops/integrations/onboarding/registry')),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await persistSummary(summary);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
route: '/ops/integrations/runtime-hosts',
|
||||||
|
actions: [
|
||||||
|
await runAction(page, '/ops/integrations/runtime-hosts', 'button:+ Add RuntimeHost', () =>
|
||||||
|
clickButtonVerify(page, '/ops/integrations/runtime-hosts', '+ Add RuntimeHost', '/ops/integrations/onboarding/host')),
|
||||||
|
await runAction(page, '/ops/integrations/runtime-hosts', 'button:Add your first runtimehost', () =>
|
||||||
|
clickButtonVerify(page, '/ops/integrations/runtime-hosts', 'Add your first runtimehost', '/ops/integrations/onboarding/host')),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await persistSummary(summary);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
route: '/ops/integrations/secrets',
|
||||||
|
actions: [
|
||||||
|
await runAction(page, '/ops/integrations/secrets', 'button:+ Add Integration', () =>
|
||||||
|
clickButtonVerify(page, '/ops/integrations/secrets', '+ Add Integration', '/ops/integrations/onboarding')),
|
||||||
|
await runAction(page, '/ops/integrations/secrets', 'button:Open add integration hub', () =>
|
||||||
|
clickButtonVerify(page, '/ops/integrations/secrets', 'Open add integration hub', '/ops/integrations/onboarding')),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await persistSummary(summary);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
route: '/ops/integrations/advisory-vex-sources',
|
||||||
|
actions: [
|
||||||
|
await runAction(page, '/ops/integrations/advisory-vex-sources', 'button:+ Add Integration', () =>
|
||||||
|
clickButtonVerify(page, '/ops/integrations/advisory-vex-sources', '+ Add Integration', '/ops/integrations/onboarding')),
|
||||||
|
await runAction(page, '/ops/integrations/advisory-vex-sources', 'button:Open add integration hub', () =>
|
||||||
|
clickButtonVerify(page, '/ops/integrations/advisory-vex-sources', 'Open add integration hub', '/ops/integrations/onboarding')),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await persistSummary(summary);
|
||||||
|
} finally {
|
||||||
|
summary.failedActionCount = results.flatMap((route) => route.actions ?? []).filter((action) => action?.ok === false).length;
|
||||||
|
summary.runtimeIssueCount =
|
||||||
|
runtime.consoleErrors.length + runtime.pageErrors.length + runtime.responseErrors.length + runtime.requestFailures.length;
|
||||||
|
await persistSummary(summary).catch(() => {});
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||||
|
if (summary.failedActionCount > 0 || summary.runtimeIssueCount > 0) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`[live-integrations-action-sweep] ${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user