Align release publisher scopes and preserve promotion submit context
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
#!/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 ?? '<null>'}`);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user