diff --git a/src/Web/StellaOps.Web/scripts/live-integrations-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-integrations-action-sweep.mjs new file mode 100644 index 000000000..9a0f776f5 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-integrations-action-sweep.mjs @@ -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; +});