Close iteration 013 release confidence operator journey repairs

This commit is contained in:
master
2026-03-15 02:16:29 +02:00
parent ac817a0597
commit 2661bfefa4
15 changed files with 1054 additions and 31 deletions

View File

@@ -82,6 +82,11 @@ const suites = [
script: 'live-releases-deployments-check.mjs',
reportPath: path.join(outputDir, 'live-releases-deployments-check.json'),
},
{
name: 'release-confidence-journey',
script: 'live-release-confidence-journey.mjs',
reportPath: path.join(outputDir, 'live-release-confidence-journey.json'),
},
{
name: 'release-promotion-submit-check',
script: 'live-release-promotion-submit-check.mjs',

View File

@@ -0,0 +1,761 @@
#!/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-release-confidence-journey.state.json');
const reportPath = path.join(outputDirectory, 'live-release-confidence-journey.auth.json');
const resultPath = path.join(outputDirectory, 'live-release-confidence-journey.json');
const screenshotDirectory = path.join(outputDirectory, 'release-confidence-journey');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const scope = {
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
timeWindow: '7d',
};
function buildScopedUrl(route, extraSearchParams = {}) {
const url = new URL(route, baseUrl);
for (const [key, value] of Object.entries(scope)) {
url.searchParams.set(key, value);
}
for (const [key, value] of Object.entries(extraSearchParams)) {
if (value === null || value === undefined || value === '') {
continue;
}
url.searchParams.set(key, String(value));
}
return url.toString();
}
function isStaticAsset(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
}
function isNavigationAbort(errorText = '') {
return /aborted|net::err_abort/i.test(errorText);
}
function createRuntime() {
return {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
}
function attachRuntime(page, 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(),
text: error instanceof Error ? error.message : String(error),
});
});
page.on('requestfailed', (request) => {
const url = request.url();
const errorText = request.failure()?.errorText ?? 'unknown';
if (isStaticAsset(url) || isNavigationAbort(errorText)) {
return;
}
runtime.requestFailures.push({
page: page.url(),
method: request.method(),
url,
error: errorText,
});
});
page.on('response', (response) => {
const url = response.url();
if (isStaticAsset(url)) {
return;
}
if (response.status() >= 400) {
runtime.responseErrors.push({
page: page.url(),
method: response.request().method(),
status: response.status(),
url,
});
}
});
}
async function settle(page, timeoutMs = 1_500) {
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
await page.waitForTimeout(timeoutMs);
}
async function headingText(page) {
const headings = page.locator('h1, main h1, main h2, [data-testid="page-title"], .page-title');
const count = await headings.count().catch(() => 0);
for (let index = 0; index < Math.min(count, 6); index += 1) {
const text = (await headings.nth(index).innerText().catch(() => '')).trim();
if (text) {
return text;
}
}
return '';
}
async function bodyText(page) {
return (await page.locator('body').innerText().catch(() => ''))
.replace(/\s+/g, ' ')
.trim();
}
async function visibleAlerts(page) {
return page
.locator('[role="alert"], .error-banner, .warning-banner, .toast, .notification, .mat-mdc-snack-bar-container')
.evaluateAll((nodes) =>
nodes
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
.filter(Boolean),
)
.catch(() => []);
}
async function captureStep(page, key, extra = {}) {
const screenshotPath = path.join(screenshotDirectory, `${key}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => {});
return {
key,
url: page.url(),
title: await page.title().catch(() => ''),
heading: await headingText(page),
alerts: await visibleAlerts(page),
screenshotPath,
...extra,
};
}
function scopeIssues(url, label) {
const issues = [];
const parsed = new URL(url);
for (const [key, expectedValue] of Object.entries(scope)) {
const actualValue = parsed.searchParams.get(key);
if (actualValue !== expectedValue) {
issues.push(`${label} missing scope ${key}=${expectedValue}; actual=${actualValue ?? '<null>'}`);
}
}
return issues;
}
function hasProblemText(text) {
return /(failed|unable|error|warning|degraded|unavailable|timed out|timeout)/i.test(text);
}
async function ensureHeading(page, pattern, issues, label) {
const heading = await headingText(page);
if (!pattern.test(heading)) {
const text = await bodyText(page);
if (!pattern.test(text)) {
issues.push(`${label} heading mismatch; actual="${heading || '<empty>'}"`);
}
}
return heading;
}
async function ensureNoProblemBanner(page, issues, label) {
const alerts = await visibleAlerts(page);
for (const alert of alerts) {
if (hasProblemText(alert)) {
issues.push(`${label} surfaced banner "${alert}"`);
}
}
}
async function waitForPath(page, pathFragment, timeoutMs = 15_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (new URL(page.url()).pathname.includes(pathFragment)) {
return true;
}
await page.waitForTimeout(250);
}
return false;
}
async function waitForTextGone(page, text, timeoutMs = 15_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const visible = await page.getByText(text, { exact: true }).isVisible().catch(() => false);
if (!visible) {
return true;
}
await page.waitForTimeout(250);
}
return false;
}
async function waitForDeploymentDetailReady(page, timeoutMs = 15_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const heading = await headingText(page);
const body = await bodyText(page);
const hasHeading = /DEP-2026-050/i.test(heading);
const hasControls =
/open evidence/i.test(body) &&
/replay verify/i.test(body) &&
/plan hash/i.test(body);
if (hasHeading || hasControls) {
return true;
}
await page.waitForTimeout(250);
}
return false;
}
async function clickStable(page, locator) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await locator.click({ timeout: 10_000 });
return;
} catch (error) {
const text = error instanceof Error ? error.message : String(error);
if (!/not attached|detached|intercepts pointer events|not stable/i.test(text)) {
throw error;
}
}
await page.waitForTimeout(250);
}
throw new Error('Element did not become clickable.');
}
async function waitForPromotionSection(page, title) {
await page.getByRole('heading', { name: title, exact: true }).waitFor({
state: 'visible',
timeout: 20_000,
});
}
async function clickStableButton(page, name) {
for (let attempt = 0; attempt < 6; attempt += 1) {
const button = page.getByRole('button', { name }).first();
await button.waitFor({ state: 'visible', timeout: 10_000 });
const disabled = await button.isDisabled().catch(() => true);
if (disabled) {
await page.waitForTimeout(350);
continue;
}
try {
await button.click({ noWaitAfter: true, timeout: 10_000 });
return;
} catch (error) {
const text = error instanceof Error ? error.message : String(error);
if (!/not attached|detached|intercepts pointer events|not stable/i.test(text)) {
throw error;
}
}
await page.waitForTimeout(350);
}
throw new Error(`Unable to click button "${name}".`);
}
async function waitForPromotionPreview(page) {
const markers = [
page.locator('.preview-summary').first(),
page.getByText('All gates passed').first(),
page.getByText('One or more gates are not passing').first(),
page.getByText('Required approvers:').first(),
];
await Promise.any(markers.map((locator) => locator.waitFor({ state: 'visible', timeout: 30_000 })));
}
async function recordStep(page, report, name, action) {
const issues = [];
const startedAt = Date.now();
try {
const details = await action(issues);
await ensureNoProblemBanner(page, issues, name);
const snapshot = await captureStep(page, name, details ?? {});
report.steps.push({
name,
ok: issues.length === 0,
durationMs: Date.now() - startedAt,
issues,
snapshot,
});
} catch (error) {
issues.push(error instanceof Error ? error.message : String(error));
const snapshot = await captureStep(page, name, {});
report.steps.push({
name,
ok: false,
durationMs: Date.now() - startedAt,
issues,
snapshot,
});
}
}
async function main() {
mkdirSync(outputDirectory, { recursive: true });
mkdirSync(screenshotDirectory, { 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,
contextOptions: {
acceptDownloads: true,
viewport: { width: 1440, height: 1200 },
},
});
const page = await context.newPage();
const runtime = createRuntime();
attachRuntime(page, runtime);
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
scope,
steps: [],
runtime,
};
try {
await recordStep(page, report, '01-releases-overview-to-deployments', async (issues) => {
await page.goto(buildScopedUrl('/releases/overview'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_500);
await ensureHeading(page, /release|overview/i, issues, 'releases overview');
const deploymentHistory = page.getByRole('link', { name: 'Deployment History' }).first();
if (!(await deploymentHistory.isVisible().catch(() => false))) {
issues.push('releases overview is missing the Deployment History link');
} else {
await clickStable(page, deploymentHistory);
await waitForPath(page, '/releases/deployments');
await settle(page, 2_000);
}
issues.push(...scopeIssues(page.url(), 'releases overview/deployments'));
const heading = await ensureHeading(page, /deployment/i, issues, 'deployments list');
return { heading };
});
await recordStep(page, report, '02-deployment-detail-evidence-and-replay', async (issues) => {
const detailUrl = buildScopedUrl('/releases/deployments/DEP-2026-050');
await page.goto(detailUrl, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_500);
await waitForDeploymentDetailReady(page);
const detailHeading = await ensureHeading(page, /DEP-2026-050/i, issues, 'deployment detail');
await clickStableButton(page, 'Open Evidence');
await page.getByText('Deployment Evidence', { exact: true }).waitFor({
state: 'visible',
timeout: 15_000,
});
const evidenceWorkspace = page.locator('.evidence-info a').first();
const evidenceHref = (await evidenceWorkspace.getAttribute('href').catch(() => null)) ?? '';
if (!evidenceHref) {
issues.push('deployment evidence tab did not expose an evidence workspace link');
} else {
await clickStable(page, evidenceWorkspace);
await waitForPath(page, '/evidence/capsules/');
await settle(page, 2_000);
issues.push(...scopeIssues(page.url(), 'deployment evidence workspace'));
await ensureHeading(page, /decision capsules|capsule|evidence/i, issues, 'evidence workspace');
}
await page.goto(detailUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page, 2_000);
await waitForDeploymentDetailReady(page);
await clickStableButton(page, 'Replay Verify');
await waitForPath(page, '/evidence/verify-replay');
await settle(page, 2_000);
issues.push(...scopeIssues(page.url(), 'deployment replay verify'));
const replayUrl = new URL(page.url());
if (replayUrl.searchParams.get('releaseId') !== 'v1.2.5') {
issues.push(`deployment replay verify expected releaseId=v1.2.5 but got ${replayUrl.searchParams.get('releaseId') ?? '<null>'}`);
}
if (!replayUrl.searchParams.get('returnTo')) {
issues.push('deployment replay verify lost returnTo context');
}
return {
detailHeading,
evidenceHref,
replayUrl: page.url(),
};
});
await recordStep(page, report, '03-decision-capsules-search-and-detail', async (issues) => {
await page.goto(buildScopedUrl('/evidence/capsules'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_500);
await ensureHeading(page, /decision capsules/i, issues, 'decision capsules');
const searchInput = page.locator('.filter-bar__search-input').first();
const searchButton = page.locator('.filter-bar__search-btn').first();
if (!(await searchInput.isVisible().catch(() => false))) {
issues.push('decision capsules search input is not visible');
}
if (!(await searchButton.isVisible().catch(() => false))) {
issues.push('decision capsules search button is not visible');
}
const inputBox = await searchInput.boundingBox().catch(() => null);
const buttonBox = await searchButton.boundingBox().catch(() => null);
if (inputBox && buttonBox) {
const overlaps =
inputBox.x < buttonBox.x + buttonBox.width &&
inputBox.x + inputBox.width > buttonBox.x &&
inputBox.y < buttonBox.y + buttonBox.height &&
inputBox.y + inputBox.height > buttonBox.y;
if (overlaps) {
issues.push('decision capsules search input overlaps the search button');
}
}
await searchInput.fill('CVE-2026');
await clickStable(page, searchButton);
await settle(page, 2_000);
const text = await bodyText(page);
if (!/no decision capsules found|decision capsules/i.test(text)) {
issues.push('decision capsules search did not render either results or a deterministic empty state');
}
return {
listUrl: page.url(),
};
});
await recordStep(page, report, '04-security-posture-to-triage-workspace', async (issues) => {
await page.goto(buildScopedUrl('/security/posture'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_500);
await waitForTextGone(page, 'Loading security overview...', 15_000);
await settle(page, 1_000);
await ensureHeading(page, /security|posture/i, issues, 'security posture');
const triageLink = page.getByRole('link', { name: 'Open triage' }).first();
if (!(await triageLink.isVisible().catch(() => false))) {
issues.push('security posture is missing the Open triage link');
} else {
await clickStable(page, triageLink);
await page.waitForURL(
(url) => url.pathname.includes('/security/triage') || url.pathname.includes('/triage/artifacts'),
{ timeout: 20_000 },
);
await settle(page, 2_000);
issues.push(...scopeIssues(page.url(), 'security posture -> triage'));
}
await ensureHeading(page, /artifact workspace|triage/i, issues, 'triage workspace list');
const triageText = await bodyText(page);
if (!/findings|components|artifacts|saved views/i.test(triageText)) {
issues.push('security triage did not render its operator pivot tabs and filters');
}
return {
triageUrl: page.url(),
};
});
await recordStep(page, report, '05-advisories-vex-tabs', async (issues) => {
await page.goto(buildScopedUrl('/security/advisories-vex'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_500);
await ensureHeading(page, /advisories|vex/i, issues, 'advisories vex');
const tabs = [
{ name: 'Providers', heading: /providers/i },
{ name: 'VEX Library', heading: /vex library/i },
{ name: 'Issuer Trust', heading: /issuer trust/i },
];
const visited = [];
for (const tab of tabs) {
const target = page.locator('nav.tabs a, nav.tabs button').filter({ hasText: tab.name }).first();
if (!(await target.isVisible().catch(() => false))) {
issues.push(`advisories vex is missing the "${tab.name}" tab`);
continue;
}
await clickStable(page, target);
await settle(page, 1_500);
const text = await bodyText(page);
if (!tab.heading.test(text)) {
issues.push(`advisories vex did not render "${tab.name}" content`);
}
issues.push(...scopeIssues(page.url(), `advisories vex tab ${tab.name}`));
visited.push(tab.name);
}
return { visitedTabs: visited };
});
await recordStep(page, report, '06-reachability-tabs', async (issues) => {
await page.goto(buildScopedUrl('/security/reachability'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_500);
await ensureHeading(page, /reachability/i, issues, 'reachability');
const witnessesTab = page.getByRole('tab', { name: /Witnesses/i }).first();
if (!(await witnessesTab.isVisible().catch(() => false))) {
issues.push('reachability is missing the Witnesses tab');
} else {
await clickStable(page, witnessesTab);
await settle(page, 1_500);
const text = await bodyText(page);
if (!/witness/i.test(text)) {
issues.push('reachability Witnesses tab did not render witness content');
}
}
const poeTab = page.getByRole('tab', { name: /PoE|Proof of Exposure/i }).first();
if (!(await poeTab.isVisible().catch(() => false))) {
issues.push('reachability is missing the PoE tab');
} else {
await clickStable(page, poeTab);
await settle(page, 1_500);
const text = await bodyText(page);
if (!/proof of exposure|poe/i.test(text)) {
issues.push('reachability PoE tab did not render PoE content');
}
}
issues.push(...scopeIssues(page.url(), 'reachability'));
return { finalUrl: page.url() };
});
await recordStep(page, report, '07-security-reports-embedded-tabs', async (issues) => {
await page.goto(buildScopedUrl('/security/reports'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_500);
await ensureHeading(page, /security reports/i, issues, 'security reports');
const reportUrl = new URL(page.url());
const startingPath = reportUrl.pathname;
const tabs = [
{ name: 'Risk Report', content: /security \/ triage|findings|components|artifacts|saved views/i },
{ name: 'VEX Ledger', content: /security \/ advisories & vex|providers|issuer trust/i },
{ name: 'Evidence Export', content: /export center|export stella(bundle)?/i },
];
const visited = [];
for (const tab of tabs) {
const target = page.getByRole('tab', { name: tab.name }).first();
if (!(await target.isVisible().catch(() => false))) {
issues.push(`security reports is missing the "${tab.name}" tab`);
continue;
}
await clickStable(page, target);
await settle(page, 2_000);
const currentPath = new URL(page.url()).pathname;
if (currentPath !== startingPath) {
issues.push(`security reports tab "${tab.name}" navigated away to ${page.url()} instead of staying embedded`);
}
const text = await bodyText(page);
if (!tab.content.test(text)) {
issues.push(`security reports tab "${tab.name}" did not render embedded content`);
}
visited.push(tab.name);
}
return { visitedTabs: visited };
});
await recordStep(page, report, '08-release-promotion-submit', async (issues) => {
await page.goto(buildScopedUrl('/releases/promotions/create'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_000);
await waitForPromotionSection(page, 'Select Bundle Version Identity');
await page.getByLabel('Release/Bundle identity').fill('rel-001');
await clickStableButton(page, 'Load Target Environments');
await waitForPromotionSection(page, 'Select Region and Environment Path');
await page.locator('#target-env').waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForFunction(() => {
const select = document.querySelector('#target-env');
return select instanceof HTMLSelectElement
&& [...select.options].some((option) => option.value === 'env-staging');
}, { timeout: 30_000 });
await page.locator('#target-env').selectOption('env-staging');
await waitForPromotionSection(page, 'Gate Preview');
await clickStableButton(page, 'Refresh Gate Preview');
await waitForPromotionPreview(page);
await clickStableButton(page, 'Next ->');
await waitForPromotionSection(page, 'Approval Context');
await page.getByLabel('Justification').fill('Release confidence journey validation.');
await clickStableButton(page, 'Next ->');
await waitForPromotionSection(page, 'Launch Promotion');
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 clickStableButton(page, 'Submit Promotion Request');
const submitResponse = await submitResponsePromise;
await page.waitForURL((url) => /^\/releases\/promotions\/(?!create$)[^/]+$/i.test(url.pathname), {
timeout: 30_000,
});
await settle(page, 2_000);
issues.push(...scopeIssues(page.url(), 'promotion detail'));
if (submitResponse.status() >= 400) {
issues.push(`promotion submit returned ${submitResponse.status()}`);
}
const errorVisible = await page.getByText('Failed to submit promotion request.').isVisible().catch(() => false);
if (errorVisible) {
issues.push('promotion submit surfaced a failure banner');
}
return {
promoteUrl: page.url(),
promoteStatus: submitResponse.status(),
};
});
await recordStep(page, report, '09-hotfix-review-and-create', async (issues) => {
await page.goto(buildScopedUrl('/releases/hotfixes'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_000);
await ensureHeading(page, /hotfix/i, issues, 'hotfix list');
const reviewLink = page.getByRole('link', { name: 'Review' }).first();
if (!(await reviewLink.isVisible().catch(() => false))) {
issues.push('hotfix list is missing the Review action');
} else {
await clickStable(page, reviewLink);
await waitForPath(page, '/releases/hotfixes/');
await settle(page, 2_000);
if (!new URL(page.url()).pathname.endsWith('/releases/hotfixes/platform-bundle-1-3-1-hotfix1')) {
issues.push(`hotfix review landed on ${page.url()} instead of the canonical hotfix detail`);
}
}
await page.goto(buildScopedUrl('/releases/hotfixes/new'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_000);
const createUrl = new URL(page.url());
if (createUrl.pathname !== '/releases/versions/new') {
issues.push(`create hotfix landed on ${page.url()} instead of /releases/versions/new`);
}
if (createUrl.searchParams.get('type') !== 'hotfix' || createUrl.searchParams.get('hotfixLane') !== 'true') {
issues.push('create hotfix did not preserve type=hotfix and hotfixLane=true');
}
issues.push(...scopeIssues(page.url(), 'hotfix create'));
await ensureHeading(page, /create release version/i, issues, 'hotfix create');
return {
createUrl: page.url(),
};
});
} finally {
const runtimeIssues = [
...runtime.consoleErrors.map((entry) => `console:${entry.page}:${entry.text}`),
...runtime.pageErrors.map((entry) => `pageerror:${entry.page}:${entry.text}`),
...runtime.requestFailures.map((entry) => `requestfailed:${entry.page}:${entry.method} ${entry.url} ${entry.error}`),
...runtime.responseErrors.map((entry) => `response:${entry.page}:${entry.status} ${entry.method} ${entry.url}`),
];
report.failedStepCount = report.steps.filter((step) => !step.ok).length;
report.runtimeIssueCount = runtimeIssues.length;
report.runtimeIssues = runtimeIssues;
writeFileSync(resultPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
if (report.failedStepCount > 0 || report.runtimeIssueCount > 0) {
process.exitCode = 1;
}
}
main().catch((error) => {
process.stderr.write(`[live-release-confidence-journey] ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -268,6 +268,23 @@ function normalizeUrl(url) {
return decodeURIComponent(url);
}
function matchesExpectedPath(finalUrl, expectedPath) {
const final = new URL(finalUrl, baseUrl);
const expected = new URL(expectedPath, baseUrl);
if (final.pathname !== expected.pathname) {
return false;
}
for (const [key, value] of expected.searchParams.entries()) {
if (final.searchParams.get(key) !== value) {
return false;
}
}
return true;
}
function shouldIgnoreConsoleError(message) {
return message === 'Failed to load resource: the server responded with a status of 401 ()';
}
@@ -316,7 +333,7 @@ async function runLinkCheck(page, route, name, expectedPath) {
const finalUrl = normalizeUrl(snapshot.url);
return {
action,
ok: finalUrl.includes(expectedPath),
ok: matchesExpectedPath(finalUrl, expectedPath),
expectedPath,
finalUrl,
snapshot,
@@ -364,7 +381,9 @@ async function runButtonCheck(page, route, name, expectedPath = null) {
const hasRuntimeAlert = snapshot.alerts.some((text) => /(error|failed|unable|timed out|unavailable)/i.test(text));
return {
action,
ok: expectedPath ? finalUrl.includes(expectedPath) : snapshot.heading.trim().length > 0 && !hasRuntimeAlert,
ok: expectedPath
? matchesExpectedPath(finalUrl, expectedPath)
: snapshot.heading.trim().length > 0 && !hasRuntimeAlert,
expectedPath,
finalUrl,
snapshot,

View File

@@ -56,6 +56,24 @@ async function navigate(page, route) {
return url;
}
async function waitForWatchlistReady(page) {
await page.getByTestId('watchlist-page').waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForFunction(
() => {
const titleReady = document.title.trim().length > 0 && document.title !== 'Stella Ops Dashboard';
const createButton = Array.from(document.querySelectorAll('button'))
.some((button) => (button.textContent || '').replace(/\s+/g, ' ').trim() === '+ New Entry');
const entriesTab = Array.from(document.querySelectorAll('[role="tab"]'))
.some((tab) => (tab.textContent || '').replace(/\s+/g, ' ').trim() === 'Entries');
const emptyState = Array.from(document.querySelectorAll('.empty-state'))
.some((node) => /(Create your first rule|No watchlist rules match)/i.test((node.textContent || '').trim()));
return titleReady && entriesTab && (createButton || emptyState);
},
null,
{ timeout: 20_000 },
);
}
async function findNav(page, label) {
const candidates = [
page.getByRole('tab', { name: label }).first(),
@@ -231,6 +249,7 @@ async function main() {
currentAction = 'route:/setup/trust-signing/watchlist/entries';
await navigate(page, '/setup/trust-signing/watchlist/entries');
await waitForWatchlistReady(page);
results.push({
action: 'route:/setup/trust-signing/watchlist/entries',
ok: (await headingText(page)).length > 0 && (await page.getByTestId('watchlist-page').count()) > 0,

View File

@@ -1,5 +1,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { WITNESS_API } from '../../core/api/witness.client';
import { ReachabilityCenterComponent } from './reachability-center.component';
describe('ReachabilityCenterComponent', () => {
@@ -9,6 +12,16 @@ describe('ReachabilityCenterComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReachabilityCenterComponent],
providers: [
provideRouter([]),
{
provide: WITNESS_API,
useValue: {
listWitnesses: () => of({ witnesses: [], totalCount: 0, page: 1, pageSize: 50 }),
verifyWitness: () => of({ isValid: true, signatureValid: true, certificateChainValid: true }),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(ReachabilityCenterComponent);
@@ -19,22 +32,66 @@ describe('ReachabilityCenterComponent', () => {
expect(component.okCount()).toBe(1);
expect(component.staleCount()).toBe(1);
expect(component.missingCount()).toBe(1);
expect(component.fleetCoveragePercent()).toBe(69);
expect(component.fleetCoveragePercent()).toBe(73);
expect(component.sensorCoveragePercent()).toBe(63);
expect(component.assetsMissingSensors().map((a) => a.assetId)).toEqual([
expect(
component
.filteredCoverageRows()
.filter((row) => row.sensorsOnline < row.sensorsExpected)
.map((row) => row.assetId)
).toEqual([
'asset-api-prod',
'asset-worker-prod',
]);
});
it('filters rows by status', () => {
component.setStatusFilter('stale');
expect(component.filteredRows().map((r) => r.assetId)).toEqual(['asset-api-prod']);
it('filters coverage rows by status', () => {
component.setCoverageStatusFilter('stale');
expect(component.filteredCoverageRows().map((row) => row.assetId)).toEqual([
'asset-api-prod',
]);
});
it('switches to missing sensor filter from indicator action', () => {
component.goToMissingSensors();
expect(component.statusFilter()).toBe('missing');
expect(component.filteredRows().map((r) => r.assetId)).toEqual(['asset-worker-prod']);
it('filters coverage rows to missing assets', () => {
component.setCoverageStatusFilter('missing');
expect(component.coverageStatusFilter()).toBe('missing');
expect(component.filteredCoverageRows().map((row) => row.assetId)).toEqual([
'asset-worker-prod',
]);
});
it('preserves ambient scope when navigating between tabs', () => {
const route = TestBed.inject(ActivatedRoute);
Object.defineProperty(route, 'snapshot', {
configurable: true,
value: {
queryParamMap: convertToParamMap({
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
timeWindow: '7d',
}),
paramMap: convertToParamMap({}),
},
});
const navigateSpy = spyOn(component.router, 'navigate').and.returnValue(
Promise.resolve(true)
);
component.showWitnesses();
expect(navigateSpy).toHaveBeenCalledWith(
['/security', 'reachability', 'witnesses'],
jasmine.objectContaining({
queryParams: jasmine.objectContaining({
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
timeWindow: '7d',
tab: 'witnesses',
}),
}),
);
});
});

View File

@@ -24,6 +24,7 @@ import {
} from '../../shared/ui';
import {
buildContextRouteParams,
readContextScopeParams,
readContextRouteParam,
readContextRouteState,
} from '../../shared/ui/context-route-state/context-route-state';
@@ -452,6 +453,7 @@ export class ReachabilityCenterComponent implements OnInit {
private buildQueryParams(tab: ReachabilityTab): Record<string, string> {
return buildContextRouteParams({
...readContextScopeParams(this.route.snapshot.queryParamMap),
tab,
returnTo: this.returnTo(),
search: this.witnessSearch().trim() || null,

View File

@@ -0,0 +1,27 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { provideRouter, RouterLink } from '@angular/router';
import { ReleaseOpsOverviewPageComponent } from './release-ops-overview-page.component';
describe('ReleaseOpsOverviewPageComponent', () => {
let fixture: ComponentFixture<ReleaseOpsOverviewPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReleaseOpsOverviewPageComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(ReleaseOpsOverviewPageComponent);
fixture.detectChanges();
});
it('preserves ambient scope on every overview door link', () => {
const links = fixture.debugElement.queryAll(By.directive(RouterLink))
.map((debugElement) => debugElement.injector.get(RouterLink));
expect(links.length).toBe(6);
expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
});
});

View File

@@ -14,12 +14,12 @@ import { RouterLink } from '@angular/router';
</header>
<div class="doors">
<a routerLink="/releases/versions">Release Versions</a>
<a routerLink="/releases/runs">Release Runs</a>
<a routerLink="/releases/approvals">Approvals Queue</a>
<a routerLink="/releases/hotfixes">Hotfixes</a>
<a routerLink="/releases/promotions">Promotions</a>
<a routerLink="/releases/deployments">Deployment History</a>
<a routerLink="/releases/versions" queryParamsHandling="merge">Release Versions</a>
<a routerLink="/releases/runs" queryParamsHandling="merge">Release Runs</a>
<a routerLink="/releases/approvals" queryParamsHandling="merge">Approvals Queue</a>
<a routerLink="/releases/hotfixes" queryParamsHandling="merge">Hotfixes</a>
<a routerLink="/releases/promotions" queryParamsHandling="merge">Promotions</a>
<a routerLink="/releases/deployments" queryParamsHandling="merge">Deployment History</a>
</div>
</section>
`,

View File

@@ -78,7 +78,7 @@ interface PlatformListResponse<T> {
<span class="chip">Policy Pack: latest</span>
<span class="chip">Snapshot: {{ confidence().status }}</span>
<span class="summary">{{ confidence().summary }}</span>
<a routerLink="/ops/operations/data-integrity">Drilldown</a>
<a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge">Drilldown</a>
</div>
@if (error()) { <div class="banner banner--error">{{ error() }}</div> }
@@ -124,12 +124,12 @@ interface PlatformListResponse<T> {
<article class="panel">
<div class="panel-header">
<h3>Top Blocking Items</h3>
<a routerLink="/security/triage">Open triage</a>
<a routerLink="/security/triage" queryParamsHandling="merge">Open triage</a>
</div>
<ul>
@for (blocker of topBlockers(); track blocker.findingId) {
<li>
<a [routerLink]="['/security/triage', blocker.findingId]">{{ blocker.cveId || blocker.findingId }}</a>
<a [routerLink]="['/security/triage', blocker.findingId]" queryParamsHandling="merge">{{ blocker.cveId || blocker.findingId }}</a>
<span>{{ blocker.releaseName }} <20> {{ blocker.region || 'global' }}/{{ blocker.environment }}</span>
</li>
} @empty {
@@ -141,12 +141,12 @@ interface PlatformListResponse<T> {
<article class="panel">
<div class="panel-header">
<h3>Expiring Waivers</h3>
<a [routerLink]="['/security/disposition']" [queryParams]="{ tab: 'vex-library' }">Disposition</a>
<a [routerLink]="['/security/disposition']" [queryParams]="{ tab: 'vex-library' }" queryParamsHandling="merge">Disposition</a>
</div>
<ul>
@for (waiver of expiringWaivers(); track waiver.findingId) {
<li>
<a [routerLink]="['/security/triage', waiver.findingId]" [queryParams]="{ tab: 'waiver' }">{{ waiver.cveId || waiver.findingId }}</a>
<a [routerLink]="['/security/triage', waiver.findingId]" [queryParams]="{ tab: 'waiver' }" queryParamsHandling="merge">{{ waiver.cveId || waiver.findingId }}</a>
<span>expires {{ expiresIn(waiver.exception.expiresAt) }}</span>
</li>
} @empty {
@@ -158,7 +158,7 @@ interface PlatformListResponse<T> {
<article class="panel">
<div class="panel-header">
<h3>Advisories & VEX Health</h3>
<a routerLink="/ops/integrations/advisory-vex-sources">Configure sources</a>
<a routerLink="/ops/integrations/advisory-vex-sources" queryParamsHandling="merge">Configure sources</a>
</div>
<p class="meta">
Conflicts: <strong>{{ conflictCount() }}</strong> <20>
@@ -187,10 +187,10 @@ interface PlatformListResponse<T> {
</p>
<ul>
<li>
<a [routerLink]="['/security/triage']" [queryParams]="{ reachability: 'unreachable' }">Inspect unknown/unreachable findings</a>
<a [routerLink]="['/security/triage']" [queryParams]="{ reachability: 'unreachable' }" queryParamsHandling="merge">Inspect unknown/unreachable findings</a>
</li>
<li>
<a routerLink="/security/reachability">Open reachability coverage board</a>
<a routerLink="/security/reachability" queryParamsHandling="merge">Open reachability coverage board</a>
</li>
</ul>
</article>

View File

@@ -59,13 +59,13 @@ type AdvisoryTab = 'providers' | 'vex-library' | 'conflicts' | 'issuer-trust';
</header>
<div class="ownership-links">
<a routerLink="/ops/integrations/advisory-vex-sources">Configure advisory feeds</a>
<a routerLink="/ops/integrations/advisory-vex-sources">Configure VEX sources</a>
<a routerLink="/ops/integrations/advisory-vex-sources" queryParamsHandling="merge">Configure advisory feeds</a>
<a routerLink="/ops/integrations/advisory-vex-sources" queryParamsHandling="merge">Configure VEX sources</a>
</div>
<nav class="tabs" aria-label="Advisories and VEX tabs">
@for (tab of tabs; track tab.id) {
<a [routerLink]="[]" [queryParams]="{ tab: tab.id }" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
<a [routerLink]="[]" [queryParams]="{ tab: tab.id }" queryParamsHandling="merge" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
}
</nav>
@@ -125,7 +125,7 @@ type AdvisoryTab = 'providers' | 'vex-library' | 'conflicts' | 'issuer-trust';
<td>{{ row.vex.status }}</td>
<td>{{ row.exception.status }}</td>
<td>{{ fmt(row.updatedAt) }}</td>
<td><a [routerLink]="['/security/triage', row.findingId]" [queryParams]="{ tab: 'vex' }">Open</a></td>
<td><a [routerLink]="['/security/triage', row.findingId]" [queryParams]="{ tab: 'vex' }" queryParamsHandling="merge">Open</a></td>
</tr>
} @empty {
<tr><td colspan="6">No VEX statements matched the current scope.</td></tr>
@@ -156,7 +156,7 @@ type AdvisoryTab = 'providers' | 'vex-library' | 'conflicts' | 'issuer-trust';
<td>{{ row.exception.status }} / {{ row.exception.approvalState }}</td>
<td>{{ row.policyAction }}</td>
<td>{{ conflictResolution(row) }}</td>
<td><a [routerLink]="['/security/triage', row.findingId]" [queryParams]="{ tab: 'policy' }">Explain</a></td>
<td><a [routerLink]="['/security/triage', row.findingId]" [queryParams]="{ tab: 'policy' }" queryParamsHandling="merge">Explain</a></td>
</tr>
} @empty {
<tr><td colspan="6">No active VEX/waiver conflicts in this scope.</td></tr>

View File

@@ -8,6 +8,19 @@ export type ContextRouteStateKey =
| 'scope'
| 'view';
export const CONTEXT_SCOPE_QUERY_KEYS = [
'tenant',
'tenantId',
'regions',
'region',
'environments',
'environment',
'timeWindow',
'stage',
] as const;
export type ContextScopeQueryKey = (typeof CONTEXT_SCOPE_QUERY_KEYS)[number];
export interface ContextRouteStateReader {
get(name: string): string | null;
}
@@ -46,6 +59,15 @@ export function readContextRouteParam(
return trimmed.length > 0 ? trimmed : null;
}
export function readContextScopeParams(
reader: ContextRouteStateReader,
): Record<ContextScopeQueryKey, string | null> {
return CONTEXT_SCOPE_QUERY_KEYS.reduce((result, key) => {
result[key] = readContextRouteParam(reader, key);
return result;
}, {} as Record<ContextScopeQueryKey, string | null>);
}
export function buildContextRouteParams(
values: Record<string, string | null | undefined>,
): Params {

View File

@@ -0,0 +1,22 @@
declare module 'node:fs/promises' {
interface DirectoryEntryLike {
name: string;
isDirectory(): boolean;
isFile(): boolean;
}
export function readFile(path: string, encoding: string): Promise<string>;
export function readdir(
path: string,
options: { withFileTypes: true },
): Promise<DirectoryEntryLike[]>;
}
declare module 'node:path' {
export function basename(path: string): string;
export function join(...parts: string[]): string;
}
declare const process: {
cwd(): string;
};

View File

@@ -84,7 +84,10 @@ if (!(globalThis as Record<string, unknown>)[angularTestEnvironmentKey]) {
);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (!message.includes('Cannot set base providers because it has already been called')) {
if (
!message.includes('Cannot set base providers because it has already been called') &&
!message.includes('A platform with a different configuration has been created')
) {
throw error;
}
}

View File

@@ -4,6 +4,7 @@
"files": [
"src/test-setup.ts",
"src/app/app.config-paths.spec.ts",
"src/app/types/node-test-setup-shim.d.ts",
"src/app/types/monaco-workers.d.ts",
"src/app/core/branding/branding.service.spec.ts",
"src/app/core/api/first-signal.client.spec.ts",
@@ -31,6 +32,8 @@
"src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts",
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
"src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",
"src/app/features/reachability/reachability-center.component.spec.ts",
"src/app/features/releases/release-ops-overview-page.component.spec.ts",
"src/app/features/registry-admin/components/plan-audit.component.spec.ts",
"src/app/features/registry-admin/registry-admin.component.spec.ts",
"src/app/features/trust-admin/trust-admin.component.spec.ts",