Close iteration 013 release confidence operator journey repairs
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
22
src/Web/StellaOps.Web/src/app/types/node-test-setup-shim.d.ts
vendored
Normal file
22
src/Web/StellaOps.Web/src/app/types/node-test-setup-shim.d.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user