#!/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 webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const outputDir = path.join(webRoot, 'output', 'playwright'); const outputPath = path.join(outputDir, 'live-ops-policy-action-sweep.json'); const authStatePath = path.join(outputDir, 'live-ops-policy-action-sweep.state.json'); const authReportPath = path.join(outputDir, 'live-ops-policy-action-sweep.auth.json'); const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; const STEP_TIMEOUT_MS = 45_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(() => []); const dialogs = await page .locator('[role="dialog"], dialog, .cdk-overlay-pane') .evaluateAll((nodes) => nodes .map((node) => (node.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 240)) .filter(Boolean) .slice(0, 3), ) .catch(() => []); const visibleInputs = await page .locator('input, textarea, select') .evaluateAll((nodes) => nodes .filter((node) => { const style = globalThis.getComputedStyle(node); return style.visibility !== 'hidden' && style.display !== 'none'; }) .map((node) => ({ tag: node.tagName, type: node.getAttribute('type') || '', name: node.getAttribute('name') || '', placeholder: node.getAttribute('placeholder') || '', value: node.value || '', })) .slice(0, 10), ) .catch(() => []); return { label, url: page.url(), title: await page.title(), heading: await headingText(page), alerts, dialogs, visibleInputs, }; } function slugify(value) { return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'step'; } async function persistSummary(summary) { summary.lastUpdatedAtUtc = new Date().toISOString(); await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); } async function captureFailureSnapshot(page, label) { return captureSnapshot(page, `failure-${slugify(label)}`).catch((error) => ({ label, error: error instanceof Error ? error.message : String(error), })); } async function runAction(page, route, label, runner) { const startedAtUtc = new Date().toISOString(); const stepName = `${route} -> ${label}`; process.stdout.write(`[live-ops-policy-action-sweep] START ${stepName}\n`); let timeoutHandle = null; const startedAt = Date.now(); 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 normalized = result && typeof result === 'object' ? result : { ok: true }; const completed = { action: normalized.action || label, ...normalized, ok: typeof normalized.ok === 'boolean' ? normalized.ok : true, startedAtUtc, durationMs: Date.now() - startedAt, }; process.stdout.write( `[live-ops-policy-action-sweep] DONE ${stepName} ok=${completed.ok} durationMs=${completed.durationMs}\n`, ); return completed; } catch (error) { const failed = { action: label, ok: false, reason: 'exception', error: error instanceof Error ? error.message : String(error), startedAtUtc, durationMs: Date.now() - startedAt, snapshot: await captureFailureSnapshot(page, stepName), }; process.stdout.write( `[live-ops-policy-action-sweep] FAIL ${stepName} error=${failed.error} durationMs=${failed.durationMs}\n`, ); return failed; } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); } } } 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 findNavigationTarget(page, name, index = 0) { const candidates = [ { role: 'link', locator: page.getByRole('link', { name }) }, { role: 'tab', locator: page.getByRole('tab', { name }) }, ]; for (const candidate of candidates) { const count = await candidate.locator.count(); if (count > index) { return { matchedRole: candidate.role, locator: candidate.locator.nth(index), }; } } return null; } async function clickLink(context, page, route, name, index = 0) { await navigate(page, route); const target = await findNavigationTarget(page, name, index); if (!target) { return { action: `link:${name}`, ok: false, reason: 'missing-link', snapshot: await captureSnapshot(page, `missing-link:${name}`), }; } const startUrl = page.url(); const popupPromise = context.waitForEvent('page', { timeout: 5_000 }).catch(() => null); await target.locator.click({ timeout: 10_000 }); const popup = await popupPromise; if (popup) { await popup.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); const result = { action: `link:${name}`, ok: true, matchedRole: target.matchedRole, mode: 'popup', targetUrl: popup.url(), title: await popup.title().catch(() => ''), }; await popup.close().catch(() => {}); return result; } await page.waitForTimeout(1_000); return { action: `link:${name}`, ok: page.url() !== startUrl, matchedRole: target.matchedRole, targetUrl: page.url(), snapshot: await captureSnapshot(page, `after-link:${name}`), }; } async function clickButton(page, route, name, index = 0) { await navigate(page, route); const locator = page.getByRole('button', { name }).nth(index); if ((await locator.count()) === 0) { return { action: `button:${name}`, ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, `missing-button:${name}`), }; } const disabledBeforeClick = await locator.isDisabled().catch(() => false); const startUrl = page.url(); const downloadPromise = page.waitForEvent('download', { timeout: 4_000 }).catch(() => null); await locator.click({ timeout: 10_000 }).catch((error) => { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); }); const download = await downloadPromise; if (download) { return { action: `button:${name}`, ok: true, mode: 'download', disabledBeforeClick, suggestedFilename: download.suggestedFilename(), snapshot: await captureSnapshot(page, `after-download:${name}`), }; } await page.waitForTimeout(1_200); return { action: `button:${name}`, ok: true, disabledBeforeClick, urlChanged: page.url() !== startUrl, snapshot: await captureSnapshot(page, `after-button:${name}`), }; } async function clickFirstAvailableButton(page, route, names) { await navigate(page, route); for (const name of names) { const locator = page.getByRole('button', { name }).first(); if ((await locator.count()) === 0) { continue; } const disabledBeforeClick = await locator.isDisabled().catch(() => false); const startUrl = page.url(); const downloadPromise = page.waitForEvent('download', { timeout: 4_000 }).catch(() => null); await locator.click({ timeout: 10_000 }).catch((error) => { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); }); const download = await downloadPromise; if (download) { return { action: `button:${name}`, ok: true, mode: 'download', disabledBeforeClick, suggestedFilename: download.suggestedFilename(), snapshot: await captureSnapshot(page, `after-download:${name}`), }; } await page.waitForTimeout(1_200); return { action: `button:${name}`, ok: true, disabledBeforeClick, urlChanged: page.url() !== startUrl, snapshot: await captureSnapshot(page, `after-button:${name}`), }; } return { action: `button:${names.join('|')}`, ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, `missing-button:${names.join('|')}`), }; } async function exerciseShadowResults(page) { const route = '/ops/policy/simulation'; await navigate(page, route); const viewButton = page.getByRole('button', { name: 'View Results' }).first(); if ((await viewButton.count()) === 0) { return { action: 'button:View Results', ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, 'policy-simulation:missing-view-results'), }; } const steps = []; const initiallyDisabled = await viewButton.isDisabled().catch(() => false); let enabledInFlow = false; let restoredDisabledState = false; if (initiallyDisabled) { const enableButton = page.getByRole('button', { name: 'Enable' }).first(); if ((await enableButton.count()) === 0) { return { action: 'button:View Results', ok: false, reason: 'disabled-without-enable', initiallyDisabled, snapshot: await captureSnapshot(page, 'policy-simulation:view-results-disabled'), }; } await enableButton.click({ timeout: 10_000 }); enabledInFlow = true; await Promise.race([ page.waitForFunction(() => { const buttons = Array.from(document.querySelectorAll('button')); const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results'); return button instanceof HTMLButtonElement && !button.disabled; }, null, { timeout: 12_000 }).catch(() => {}), page.waitForTimeout(2_000), ]); steps.push({ step: 'enable-shadow-mode', snapshot: await captureSnapshot(page, 'policy-simulation:enabled-shadow-mode'), }); } const activeViewButton = page.getByRole('button', { name: 'View Results' }).first(); const stillDisabled = await activeViewButton.isDisabled().catch(() => false); if (stillDisabled) { return { action: 'button:View Results', ok: false, reason: 'still-disabled-after-enable', initiallyDisabled, enabledInFlow, steps, snapshot: await captureSnapshot(page, 'policy-simulation:view-results-still-disabled'), }; } const startUrl = page.url(); await activeViewButton.click({ timeout: 10_000 }); await page.waitForTimeout(1_200); const resultsUrl = page.url(); const resultsSnapshot = await captureSnapshot(page, 'policy-simulation:view-results'); if (enabledInFlow) { await navigate(page, route); const disableButton = page.getByRole('button', { name: 'Disable' }).first(); if ((await disableButton.count()) > 0 && !(await disableButton.isDisabled().catch(() => false))) { await disableButton.click({ timeout: 10_000 }); await page.waitForTimeout(1_200); restoredDisabledState = true; steps.push({ step: 'restore-shadow-disabled', snapshot: await captureSnapshot(page, 'policy-simulation:restored-shadow-disabled'), }); } } return { action: 'button:View Results', ok: resultsUrl !== startUrl && resultsUrl.includes('/ops/policy/simulation/history'), initiallyDisabled, enabledInFlow, restoredDisabledState, targetUrl: resultsUrl, snapshot: resultsSnapshot, steps, }; } async function checkDisabledButton(page, route, name) { await navigate(page, route); const locator = page.getByRole('button', { name }).first(); if ((await locator.count()) === 0) { return { action: `button:${name}`, ok: false, reason: 'missing-button' }; } return { action: `button:${name}`, ok: true, disabled: await locator.isDisabled().catch(() => false), snapshot: await captureSnapshot(page, `disabled-check:${name}`), }; } async function notificationsFormProbe(context, page) { const route = '/ops/operations/notifications'; const actions = []; actions.push(await runAction(page, route, 'flow:New channel', async () => { await navigate(page, route); const newChannel = page.getByRole('button', { name: 'New channel' }).first(); if ((await newChannel.count()) === 0) { return { action: 'flow:New channel', ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, 'notifications:missing-new-channel'), }; } await newChannel.click({ timeout: 10_000 }); await page.waitForTimeout(800); const flowSteps = [ { step: 'button:New channel', snapshot: await captureSnapshot(page, 'notifications:new-channel'), }, ]; const firstTextInput = page.locator('input[type="text"], input:not([type]), textarea').first(); if ((await firstTextInput.count()) > 0) { await firstTextInput.fill('Live QA Channel'); flowSteps.push({ step: 'fill:channel-name', value: 'Live QA Channel' }); } const sendTest = page.getByRole('button', { name: 'Send test' }).first(); if ((await sendTest.count()) > 0) { await sendTest.click({ timeout: 10_000 }).catch(() => {}); await page.waitForTimeout(1_200); flowSteps.push({ step: 'button:Send test', snapshot: await captureSnapshot(page, 'notifications:send-test'), }); } const saveChannel = page.getByRole('button', { name: 'Save channel' }).first(); if ((await saveChannel.count()) > 0) { await saveChannel.click({ timeout: 10_000 }).catch(() => {}); await page.waitForTimeout(1_200); flowSteps.push({ step: 'button:Save channel', snapshot: await captureSnapshot(page, 'notifications:save-channel'), }); } return { action: 'flow:New channel', ok: true, steps: flowSteps, snapshot: await captureSnapshot(page, 'notifications:new-channel-finished'), }; })); actions.push(await runAction(page, route, 'flow:New rule', async () => { await navigate(page, route); const newRule = page.getByRole('button', { name: 'New rule' }).first(); if ((await newRule.count()) === 0) { return { action: 'flow:New rule', ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, 'notifications:missing-new-rule'), }; } await newRule.click({ timeout: 10_000 }); await page.waitForTimeout(800); const flowSteps = [ { step: 'button:New rule', snapshot: await captureSnapshot(page, 'notifications:new-rule'), }, ]; const saveRule = page.getByRole('button', { name: 'Save rule' }).first(); if ((await saveRule.count()) > 0) { await saveRule.click({ timeout: 10_000 }).catch(() => {}); await page.waitForTimeout(1_200); flowSteps.push({ step: 'button:Save rule', snapshot: await captureSnapshot(page, 'notifications:save-rule'), }); } return { action: 'flow:New rule', ok: true, steps: flowSteps, snapshot: await captureSnapshot(page, 'notifications:new-rule-finished'), }; })); actions.push(await runAction(page, route, 'link:Open watchlist tuning', () => clickLink(context, page, route, 'Open watchlist tuning'))); actions.push(await runAction(page, route, 'link:Review watchlist alerts', () => clickLink(context, page, route, 'Review watchlist alerts'))); return { route, actions }; } 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 }); context.setDefaultTimeout(15_000); context.setDefaultNavigationTimeout(30_000); const page = await context.newPage(); page.setDefaultTimeout(15_000); page.setDefaultNavigationTimeout(30_000); const runtime = { consoleErrors: [], pageErrors: [], responseErrors: [], requestFailures: [], }; const results = []; const summary = { generatedAtUtc: new Date().toISOString(), results, runtime, }; 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, }); } }); try { results.push({ route: '/ops/operations/quotas', actions: [ await runAction(page, '/ops/operations/quotas', 'button:Configure Alerts', () => clickButton(page, '/ops/operations/quotas', 'Configure Alerts')), await runAction(page, '/ops/operations/quotas', 'button:Export Report', () => clickButton(page, '/ops/operations/quotas', 'Export Report')), await runAction(page, '/ops/operations/quotas', 'link:View Details', () => clickLink(context, page, '/ops/operations/quotas', 'View Details')), await runAction(page, '/ops/operations/quotas', 'link:Default Tenant', () => clickLink(context, page, '/ops/operations/quotas', 'Default Tenant')), ], }); await persistSummary(summary); results.push({ route: '/ops/operations/dead-letter', actions: [ await runAction(page, '/ops/operations/dead-letter', 'button:Export CSV', () => clickButton(page, '/ops/operations/dead-letter', 'Export CSV')), await runAction(page, '/ops/operations/dead-letter', 'button:Replay All Retryable (0)', () => checkDisabledButton(page, '/ops/operations/dead-letter', 'Replay All Retryable (0)')), await runAction(page, '/ops/operations/dead-letter', 'button:Clear', () => clickButton(page, '/ops/operations/dead-letter', 'Clear')), await runAction(page, '/ops/operations/dead-letter', 'link:View Full Queue', () => clickLink(context, page, '/ops/operations/dead-letter', 'View Full Queue')), ], }); await persistSummary(summary); results.push({ route: '/ops/operations/aoc', actions: [ await runAction(page, '/ops/operations/aoc', 'button:Refresh', () => clickButton(page, '/ops/operations/aoc', 'Refresh')), await runAction(page, '/ops/operations/aoc', 'button:Validate', () => clickButton(page, '/ops/operations/aoc', 'Validate')), await runAction(page, '/ops/operations/aoc', 'link:Export Report', () => clickLink(context, page, '/ops/operations/aoc', 'Export Report')), await runAction(page, '/ops/operations/aoc', 'link:View All', () => clickLink(context, page, '/ops/operations/aoc', 'View All')), await runAction(page, '/ops/operations/aoc', 'link:Details', () => clickLink(context, page, '/ops/operations/aoc', 'Details')), await runAction(page, '/ops/operations/aoc', 'link:Full Validator', () => clickLink(context, page, '/ops/operations/aoc', 'Full Validator')), ], }); await persistSummary(summary); results.push(await notificationsFormProbe(context, page)); await persistSummary(summary); results.push({ route: '/ops/policy/simulation', actions: [ await runAction(page, '/ops/policy/simulation', 'button:View Results', () => exerciseShadowResults(page)), await runAction(page, '/ops/policy/simulation', 'button:Enable|Disable', () => clickFirstAvailableButton(page, '/ops/policy/simulation', ['Enable', 'Disable'])), await runAction(page, '/ops/policy/simulation', 'link:Simulation Console', () => clickLink(context, page, '/ops/policy/simulation', 'Simulation Console')), await runAction(page, '/ops/policy/simulation', 'link:Coverage', () => clickLink(context, page, '/ops/policy/simulation', 'Coverage')), ], }); await persistSummary(summary); results.push({ route: '/ops/policy/staleness', actions: [ await runAction(page, '/ops/policy/staleness', 'button:SBOM', () => clickButton(page, '/ops/policy/staleness', 'SBOM')), await runAction(page, '/ops/policy/staleness', 'button:Vulnerability Data', () => clickButton(page, '/ops/policy/staleness', 'Vulnerability Data')), await runAction(page, '/ops/policy/staleness', 'button:VEX Statements', () => clickButton(page, '/ops/policy/staleness', 'VEX Statements')), await runAction(page, '/ops/policy/staleness', 'button:Save Changes', () => clickButton(page, '/ops/policy/staleness', 'Save Changes')), await runAction(page, '/ops/policy/staleness', 'link:Reset view', () => clickLink(context, page, '/ops/policy/staleness', 'Reset view')), ], }); 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-ops-policy-action-sweep] ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; });