diff --git a/docs/implplan/SPRINT_20260314_003_Platform_scratch_iteration_013_full_route_action_audit.md b/docs/implplan/SPRINT_20260314_003_Platform_scratch_iteration_013_full_route_action_audit.md new file mode 100644 index 000000000..e4e7ff8ed --- /dev/null +++ b/docs/implplan/SPRINT_20260314_003_Platform_scratch_iteration_013_full_route_action_audit.md @@ -0,0 +1,83 @@ +# Sprint 20260314_003 - Platform Scratch Iteration 013 Release Confidence Operator Journey Audit + +## Topic & Scope +- Use Stella Ops as an end-user release operator who is trying to decide whether a release can be promoted with confidence. +- Drive the product through real operator journeys first: release overview, deployment evidence, findings and VEX review, reachability and exposure review, approval or rejection, promotion, and hotfix follow-through. +- Treat automated wipe/setup and retained Playwright sweeps as guardrails, not the purpose of the iteration; every newly discovered manual gap must become retained Playwright coverage afterward. +- Group any fresh failures by root cause before implementing fixes so the commit closes a full release-confidence iteration rather than isolated page patches. +- Working directory: `.`. +- Expected evidence: journey notes, Playwright artifacts for the operator flows, retained scenario updates for newly discovered steps, grouped defect analysis, focused tests, and rebuilt-stack retest results. + +## Dependencies & Concurrency +- Depends on local commit `ac817a059` as the closed baseline from scratch iteration 012. +- Safe parallelism: none during wipe/setup because the environment reset is global to the machine. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/INSTALL_GUIDE.md` +- `docs/dev/DEV_ENVIRONMENT_SETUP.md` +- `docs/qa/feature-checks/FLOW.md` + +## Delivery Tracker + +### PLATFORM-SCRATCH-ITER13-001 - Define and run release-confidence operator journeys +Status: DONE +Dependency: none +Owners: QA, Product Manager +Task description: +- Act as an operator using Stella Ops to decide whether a release is safe to promote. The baseline journey must cover: release overview, release/deployment detail, security posture, triage, advisories/VEX, reachability, evidence threads/capsules/proofs, approvals/promotions, and hotfix handling. + +Completion criteria: +- [x] The primary operator journeys are explicitly listed before fixes begin. +- [x] Playwright is used to execute those journeys as a user would, not only as route sweeps. +- [x] Every broken route, page-load, data-load, or action encountered on the operator path is recorded before any fix starts. + +### PLATFORM-SCRATCH-ITER13-002 - Convert newly discovered manual steps into retained coverage +Status: DONE +Dependency: PLATFORM-SCRATCH-ITER13-001 +Owners: QA, Test Automation +Task description: +- After the operator journey exposes gaps, add or deepen retained Playwright so the exact end-user steps become part of future iterations instead of being rediscovered manually. + +Completion criteria: +- [x] Every newly discovered operator step is mapped to retained Playwright coverage or an explicit backlog gap. +- [x] Retained coverage additions are scoped by user journey, not just by route. +- [x] The next aggregate run would exercise the newly discovered operator path automatically. + +### PLATFORM-SCRATCH-ITER13-003 - Repair grouped release-confidence defects and retest +Status: DONE +Dependency: PLATFORM-SCRATCH-ITER13-002 +Owners: 3rd line support, Architect, Developer +Task description: +- Diagnose the grouped failures exposed by the operator journey, choose the clean product/architecture-conformant fix, implement it, add retained Playwright coverage for the new behavior when needed, and rerun the affected journeys plus the aggregate audit before committing. + +Completion criteria: +- [x] Root causes are recorded for the grouped failures. +- [x] Fixes land with focused regression coverage and retained Playwright scenario updates where practical. +- [x] The rebuilt stack is retested through the same operator journeys before the iteration commit. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-14 | Sprint created immediately after local commit `ac817a059` closed scratch iteration 012 cleanly at `24/24` suites and `111/111` routes. | QA | +| 2026-03-14 | Iteration reframed from aggregate-only scratch QA to release-confidence operator journeys after the explicit requirement to use Stella Ops as an end user deciding whether a release is safe. | QA / Product Manager | +| 2026-03-14 | Added a retained Playwright operator journey in `src/Web/StellaOps.Web/scripts/live-release-confidence-journey.mjs` that walks release overview, deployment evidence and replay, decision capsules, security posture to triage, advisories/VEX, reachability, security reports, promotion submission, and hotfix creation as a release operator would. The first live run exposed four real failures before any fixes started: releases overview -> deployments dropped ambient scope, security posture -> triage dropped ambient scope, advisories/VEX tabs dropped ambient scope, and reachability tab navigation dropped ambient scope. | QA | +| 2026-03-14 | Diagnosed the grouped root cause as inconsistent context-scope propagation between declarative router links and programmatic tab navigation. The clean fix was to centralize scope query handling in `context-route-state.ts`, add `queryParamsHandling=\"merge\"` to the affected release and security links, and teach reachability tab navigation to merge ambient scope instead of rebuilding query params from only local state. | 3rd line support / Architect | +| 2026-03-14 | Converted the new operator steps into retained coverage by wiring the release-confidence journey into `live-full-core-audit.mjs`, adding focused Angular regressions for release overview and reachability scope preservation, and modernizing the Angular feature-spec harness (`src/test-setup.ts`, `tsconfig.spec.features.json`, and `src/app/types/node-test-setup-shim.d.ts`) so the retained specs run under the current Vitest-based setup. | QA / Test Automation / Developer | +| 2026-03-14 | Focused verification passed: `npx ng test --watch=false --progress=false --ts-config tsconfig.spec.features.json --include=src/app/features/releases/release-ops-overview-page.component.spec.ts --include=src/app/features/reachability/reachability-center.component.spec.ts` returned `5/5`, `npm run build` passed, the rebuilt browser dist was synced into `compose_console-dist`, `stellaops-router-gateway` was restarted healthy, and `node ./scripts/live-release-confidence-journey.mjs` reran clean with `failedStepCount=0` and `runtimeIssueCount=0`. | QA / Developer | +| 2026-03-14 | The first post-fix aggregate run surfaced two retained-coverage defects, not product regressions: `live-watchlist-action-sweep.mjs` was asserting the trust watchlist route before the shell finished hydrating on direct entry, and `live-uncovered-surface-action-sweep.mjs` still matched exact query strings after ambient scope preservation intentionally added extra query keys. Both harnesses were corrected to wait for real ready-state and to validate required path/query subsets instead of brittle full-URL substrings. | QA / 3rd line support | +| 2026-03-14 | A second aggregate run exposed one more retained journey defect on the deployment-detail step of `live-release-confidence-journey.mjs`: the detail page was healthy on direct load, but the journey asserted the heading before the deployment shell was fully ready under aggregate load. The journey now waits for deployment detail readiness (`DEP-2026-050`, plan hash, evidence/replay controls) before asserting or branching into evidence and replay. | QA / 3rd line support / Developer | +| 2026-03-15 | Final full-stack rerun closed clean after the retained fixes: `node ./scripts/live-full-core-audit.mjs` finished `25/25` suites passed, `0` failed, `0` retried, `0` stabilized-after-retry; the release-confidence journey, admin/trust checks, integrations fixture onboarding, topology actions, watchlist CRUD, uncovered-surface actions, and search-result actions all passed on the same live stack. | QA | + +## Decisions & Risks +- Decision: route sweeps and retained aggregate audits remain necessary, but they are regression guardrails. The source of truth for this iteration is the end-user release-confidence workflow. +- Decision: any newly discovered manual operator step must become retained Playwright coverage before iteration 013 may close. +- Risk: some currently green surfaces may still be shallow if they have not been exercised through a real operator journey; those gaps must be surfaced explicitly instead of hidden behind aggregate passes. +- Decision: the grouped defect family for this iteration is "ambient scope preservation across release-confidence handoffs"; fixing it at the shared routing/state layer is preferable to page-by-page patches because the operator journey crosses releases, security posture, VEX, and reachability in one flow. +- Decision: the retained reachability scope regression belongs in `reachability-center.component.spec.ts`, not a duplicate one-off spec, so the revived component keeps one canonical focused coverage file. +- Decision: retained Playwright checks that validate navigations must compare path plus required query-param subsets, not brittle full URL strings, because operator scope propagation is intentionally additive across the shell. +- Decision: direct-entry trust/watchlist and deployment-detail journeys require explicit ready-state waits in retained coverage; asserting too early creates false negatives that mask the real product state. + +## Next Checkpoints +- Start the next operator-first iteration from fresh Stella state and widen retained behavior coverage for surfaces that are still mostly route-verified rather than journey-verified. +- Keep adding dedicated user journeys for remaining setup/admin and integration-management surfaces as they are exercised manually. diff --git a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs index d9d4bebf4..ecbe8e57c 100644 --- a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs +++ b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs @@ -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', diff --git a/src/Web/StellaOps.Web/scripts/live-release-confidence-journey.mjs b/src/Web/StellaOps.Web/scripts/live-release-confidence-journey.mjs new file mode 100644 index 000000000..767262314 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-release-confidence-journey.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 ?? ''}`); + } + } + + 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 || ''}"`); + } + } + + 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') ?? ''}`); + } + 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); +}); diff --git a/src/Web/StellaOps.Web/scripts/live-uncovered-surface-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-uncovered-surface-action-sweep.mjs index 25cc00568..ee97198a7 100644 --- a/src/Web/StellaOps.Web/scripts/live-uncovered-surface-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-uncovered-surface-action-sweep.mjs @@ -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, diff --git a/src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs index 0ba7c171c..05a34e468 100644 --- a/src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs @@ -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, diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.spec.ts index 118ceda1c..b364c4599 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.spec.ts @@ -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', + }), + }), + ); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts index 21987c48e..d3ef9a8a2 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts @@ -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 { return buildContextRouteParams({ + ...readContextScopeParams(this.route.snapshot.queryParamMap), tab, returnTo: this.returnTo(), search: this.witnessSearch().trim() || null, diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.spec.ts new file mode 100644 index 000000000..c73427287 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts index a5adbc648..bf9d5281e 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts @@ -14,12 +14,12 @@ import { RouterLink } from '@angular/router'; `, diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts index 3befe741e..26a7c23e5 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts @@ -78,7 +78,7 @@ interface PlatformListResponse { Policy Pack: latest Snapshot: {{ confidence().status }} {{ confidence().summary }} - Drilldown + Drilldown @if (error()) { } @@ -124,12 +124,12 @@ interface PlatformListResponse {

Top Blocking Items

- Open triage + Open triage
    @for (blocker of topBlockers(); track blocker.findingId) {
  • - {{ blocker.cveId || blocker.findingId }} + {{ blocker.cveId || blocker.findingId }} {{ blocker.releaseName }} � {{ blocker.region || 'global' }}/{{ blocker.environment }}
  • } @empty { @@ -141,12 +141,12 @@ interface PlatformListResponse {

    Expiring Waivers

    - Disposition + Disposition
      @for (waiver of expiringWaivers(); track waiver.findingId) {
    • - {{ waiver.cveId || waiver.findingId }} + {{ waiver.cveId || waiver.findingId }} expires {{ expiresIn(waiver.exception.expiresAt) }}
    • } @empty { @@ -158,7 +158,7 @@ interface PlatformListResponse { diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts index fbffb4400..a5698892e 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts @@ -59,13 +59,13 @@ type AdvisoryTab = 'providers' | 'vex-library' | 'conflicts' | 'issuer-trust'; @@ -125,7 +125,7 @@ type AdvisoryTab = 'providers' | 'vex-library' | 'conflicts' | 'issuer-trust'; {{ row.vex.status }} {{ row.exception.status }} {{ fmt(row.updatedAt) }} - Open + Open } @empty { No VEX statements matched the current scope. @@ -156,7 +156,7 @@ type AdvisoryTab = 'providers' | 'vex-library' | 'conflicts' | 'issuer-trust'; {{ row.exception.status }} / {{ row.exception.approvalState }} {{ row.policyAction }} {{ conflictResolution(row) }} - Explain + Explain } @empty { No active VEX/waiver conflicts in this scope. diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts b/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts index 00cd3667e..0ae530f99 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts @@ -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 { + return CONTEXT_SCOPE_QUERY_KEYS.reduce((result, key) => { + result[key] = readContextRouteParam(reader, key); + return result; + }, {} as Record); +} + export function buildContextRouteParams( values: Record, ): Params { diff --git a/src/Web/StellaOps.Web/src/app/types/node-test-setup-shim.d.ts b/src/Web/StellaOps.Web/src/app/types/node-test-setup-shim.d.ts new file mode 100644 index 000000000..ce2db3a19 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/types/node-test-setup-shim.d.ts @@ -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; + export function readdir( + path: string, + options: { withFileTypes: true }, + ): Promise; +} + +declare module 'node:path' { + export function basename(path: string): string; + export function join(...parts: string[]): string; +} + +declare const process: { + cwd(): string; +}; diff --git a/src/Web/StellaOps.Web/src/test-setup.ts b/src/Web/StellaOps.Web/src/test-setup.ts index 34135e106..9fdb98eb1 100644 --- a/src/Web/StellaOps.Web/src/test-setup.ts +++ b/src/Web/StellaOps.Web/src/test-setup.ts @@ -84,7 +84,10 @@ if (!(globalThis as Record)[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; } } diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index b598a0c6d..905606800 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -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",