#!/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 statePath = path.join(outputDirectory, 'live-frontdoor-auth-state.json'); const reportPath = path.join(outputDirectory, 'live-frontdoor-auth-report.json'); const resultPath = path.join(outputDirectory, 'live-release-promotion-submit-check.json'); const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; const createRoute = `/releases/promotions/create?${scopeQuery}`; const promotionDetailPattern = /^\/releases\/promotions\/(?!create$)[^/]+$/i; const expectedScopeEntries = [ ['tenant', 'demo-prod'], ['regions', 'us-east'], ['environments', 'stage'], ['timeWindow', '7d'], ]; function isStaticAsset(url) { return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url); } function isAbortedNavigationFailure(failure) { if (!failure) { return false; } return /aborted|net::err_abort/i.test(failure); } function collectScopeIssues(label, targetUrl) { const issues = []; const parsed = new URL(targetUrl); for (const [key, expectedValue] of expectedScopeEntries) { const actualValue = parsed.searchParams.get(key); if (actualValue !== expectedValue) { issues.push(`${label} missing preserved scope ${key}=${expectedValue}; actual=${actualValue ?? ''}`); } } return issues; } async function clickNext(page) { await page.getByRole('button', { name: 'Next ->' }).click(); } async function main() { mkdirSync(outputDirectory, { recursive: true }); const authReport = await authenticateFrontdoor({ statePath, reportPath, headless: true, }); const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'], }); const context = await createAuthenticatedContext(browser, authReport, { statePath }); const page = await context.newPage(); const runtimeIssues = []; const responseErrors = []; const requestFailures = []; const consoleErrors = []; let promoteResponse = null; page.on('console', (message) => { if (message.type() === 'error') { consoleErrors.push(message.text()); } }); page.on('requestfailed', (request) => { const url = request.url(); if (isStaticAsset(url) || isAbortedNavigationFailure(request.failure()?.errorText)) { return; } requestFailures.push({ method: request.method(), url, error: request.failure()?.errorText ?? 'unknown', page: page.url(), }); }); page.on('response', async (response) => { const url = response.url(); if (isStaticAsset(url)) { return; } if (url.includes('/api/v1/release-orchestrator/releases/') && url.endsWith('/promote')) { try { promoteResponse = { status: response.status(), url, body: await response.json(), }; } catch { promoteResponse = { status: response.status(), url, body: null, }; } } if (response.status() >= 400) { responseErrors.push({ status: response.status(), method: response.request().method(), url, page: page.url(), }); } }); const result = { checkedAtUtc: new Date().toISOString(), route: createRoute, finalUrl: null, promoteResponse, scopeIssues: [], consoleErrors, requestFailures, responseErrors, runtimeIssues, runtimeIssueCount: 0, }; try { await page.goto(`https://stella-ops.local${createRoute}`, { waitUntil: 'domcontentloaded', timeout: 30_000, }); await page.getByLabel('Release/Bundle identity').fill('rel-001'); await page.getByRole('button', { name: 'Load Target Environments' }).click(); await page.locator('#target-env').selectOption('env-staging'); await page.getByRole('button', { name: 'Refresh Gate Preview' }).click(); await clickNext(page); await page.getByLabel('Justification').fill('Release approval path validated end to end.'); await clickNext(page); const submitResponsePromise = page.waitForResponse( (response) => response.request().method() === 'POST' && response.url().includes('/api/v1/release-orchestrator/releases/') && response.url().endsWith('/promote'), { timeout: 30_000 }, ); await page.getByRole('button', { name: 'Submit Promotion Request' }).click({ noWaitAfter: true, timeout: 10_000, }); const submitResponse = await submitResponsePromise; result.promoteResponse = promoteResponse ?? { status: submitResponse.status(), url: submitResponse.url(), body: null, }; await page.waitForURL((url) => promotionDetailPattern.test(url.pathname), { timeout: 30_000, }); result.finalUrl = page.url(); result.scopeIssues.push(...collectScopeIssues('finalUrl', result.finalUrl)); const finalPathname = new URL(result.finalUrl).pathname; if (!promotionDetailPattern.test(finalPathname)) { runtimeIssues.push(`Promotion submit did not land on a canonical detail route; actual path=${finalPathname}`); } if ((result.promoteResponse?.status ?? 0) >= 400) { runtimeIssues.push(`Promotion submit returned ${result.promoteResponse.status}`); } const errorBannerVisible = await page.getByText('Failed to submit promotion request.').isVisible().catch(() => false); if (errorBannerVisible) { runtimeIssues.push('Promotion submit surfaced an error banner after submit.'); } } catch (error) { runtimeIssues.push(error instanceof Error ? error.message : String(error)); result.finalUrl = page.url(); } finally { result.runtimeIssues = [ ...runtimeIssues, ...result.scopeIssues, ...responseErrors.map((entry) => `${entry.method} ${entry.url} -> ${entry.status}`), ...requestFailures.map((entry) => `${entry.method} ${entry.url} failed: ${entry.error}`), ...consoleErrors, ]; result.runtimeIssueCount = result.runtimeIssues.length; writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); await context.close(); await browser.close(); } if (result.runtimeIssueCount > 0) { throw new Error(result.runtimeIssues.join('; ')); } process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } main().catch((error) => { process.stderr.write(`[live-release-promotion-submit-check] ${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); });