From 7bdfcd505579fec64434405bcc5cb3c286364e67 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 15 Mar 2026 04:04:36 +0200 Subject: [PATCH] Stabilize release confidence approval decision journey --- ...lease_confidence_operator_journey_audit.md | 76 +++++++++++ .../live-release-confidence-journey.mjs | 70 ++++++++++ .../approval-detail-page.component.spec.ts | 121 ++++++++++++++++++ .../approval-detail-page.component.ts | 61 ++++++--- .../features/approvals/approvals.routes.ts | 8 +- .../StellaOps.Web/tsconfig.spec.features.json | 1 + 6 files changed, 314 insertions(+), 23 deletions(-) create mode 100644 docs/implplan/SPRINT_20260315_002_Platform_release_confidence_operator_journey_audit.md create mode 100644 src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.spec.ts diff --git a/docs/implplan/SPRINT_20260315_002_Platform_release_confidence_operator_journey_audit.md b/docs/implplan/SPRINT_20260315_002_Platform_release_confidence_operator_journey_audit.md new file mode 100644 index 000000000..1dfcd6a60 --- /dev/null +++ b/docs/implplan/SPRINT_20260315_002_Platform_release_confidence_operator_journey_audit.md @@ -0,0 +1,76 @@ +# Sprint 20260315_002 - Platform Release Confidence Operator Journey Audit + +## Topic & Scope +- Use Stella Ops as a release operator trying to gain confidence in a production promotion decision. +- Drive the release-control journey end to end: mission control to approvals, promotions, deployments, release health, hotfixes, release evidence, and the adjacent security/evidence pivots a release operator actually uses while deciding. +- Treat Playwright as retained evidence only after manual discovery. Every newly discovered release-confidence step or defect becomes retained coverage before the sprint closes. +- Group fixes by root cause so the iteration closes whole release-confidence behavior slices, not isolated page patches. +- Working directory: `.`. +- Expected evidence: operator journey notes, retained Playwright additions, grouped defect analysis, focused regression tests where code changes land, rebuilt-stack retest results, and live journey evidence. + +## Dependencies & Concurrency +- Depends on local commit `4a5185121` as the closed baseline from the setup/admin operator journey. +- Safe parallelism: avoid environment resets while the live release-confidence journey is being exercised because releases, approvals, and evidence surfaces share the same stack state. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/INSTALL_GUIDE.md` +- `docs/dev/DEV_ENVIRONMENT_SETUP.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### PLATFORM-RELEASE-CONFIDENCE-001 - Define and execute the release-confidence operator journey +Status: DONE +Dependency: none +Owners: QA, Product Manager +Task description: +- Act as a release operator preparing to promote or hotfix with confidence. Walk the visible release-control flow the way a user would: entry from mission control, release health, approvals, promotions, deployment detail, version detail, hotfix flow, and evidence hand-offs needed to justify a decision. + +Completion criteria: +- [x] The release-confidence operator journey is explicitly listed before fixes begin. +- [x] Playwright is used to execute the journey as an operator would, not only as a route sweep. +- [x] Every broken route, page-load, data-load, hand-off, validation rule, or action encountered on the release path is recorded before any fix starts. + +### PLATFORM-RELEASE-CONFIDENCE-002 - Convert newly discovered release steps into retained coverage +Status: DONE +Dependency: PLATFORM-RELEASE-CONFIDENCE-001 +Owners: QA, Test Automation +Task description: +- Add or deepen retained Playwright coverage for every newly discovered release-confidence step so future iterations automatically recheck the same operator behavior. + +Completion criteria: +- [x] Every newly discovered operator/release step is mapped to retained Playwright coverage or an explicit backlog gap. +- [x] Retained coverage additions are organized by user journey, not only by route. +- [x] The next aggregate run would exercise the newly discovered release-confidence path automatically. + +### PLATFORM-RELEASE-CONFIDENCE-003 - Repair grouped release-confidence defects and retest +Status: DONE +Dependency: PLATFORM-RELEASE-CONFIDENCE-002 +Owners: 3rd line support, Architect, Developer +Task description: +- Diagnose the grouped failures exposed by the release-confidence 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 live stack is retested through the same release-confidence journeys before the iteration commit. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-15 | Sprint created immediately after local commit `4a5185121` closed the setup/admin operator journey. | QA | +| 2026-03-15 | Executed the release-confidence operator path from releases overview through deployments, approval detail, decision capsules, triage, advisories/VEX, reachability, security reports, promotions, and hotfix creation before fixing anything. | QA | +| 2026-03-15 | Converted the approval-detail operator handoffs into retained Playwright coverage in `live-release-confidence-journey.mjs`, including the real decision cockpit route and scope-preservation assertions for Reachability, Ops/Data, and gate-fix pivots. | Test Automation | +| 2026-03-15 | Root-caused the release-confidence defect set to the approval detail route loading a legacy placeholder component and the real cockpit dropping active operator scope on handoff links. Fixed the canonical route, centralized handoff query-param preservation in `approval-detail-page.component.ts`, added focused Angular regression coverage, rebuilt the web bundle, redeployed, and reran the live release-confidence journey clean with `failedStepCount=0` and `runtimeIssueCount=0`. | QA / 3rd line support / Architect / Developer | + +## Decisions & Risks +- Decision: this iteration prioritizes release-confidence behavior over broad route counts. +- Risk: several release-control surfaces already have route/action sweeps, but the full operator decision journey may still have hand-off gaps that only appear when used sequentially as a real user. +- Decision: operator scope (`tenant`, `regions`, `environments`, `timeWindow`) must survive approval-detail pivots the same way it survives deployment-detail pivots; preserving that scope is part of the release-confidence contract, not optional UI state. +- Root cause: `/releases/approvals/:id` still pointed at a legacy placeholder `approval-detail.component` while the actual decision cockpit lived in `approval-detail-page.component`; once the route was corrected, the retained journey exposed that cockpit handoffs generated canonical paths but discarded current operator scope because plain `routerLink` anchors and gate-fix links were not built from a shared scope-preserving query-param contract. + +## Next Checkpoints +- Define the exact release-confidence path before fixing anything. +- Run the journey manually with Playwright, then convert newly discovered steps into retained coverage. diff --git a/src/Web/StellaOps.Web/scripts/live-release-confidence-journey.mjs b/src/Web/StellaOps.Web/scripts/live-release-confidence-journey.mjs index 767262314..dc9808617 100644 --- a/src/Web/StellaOps.Web/scripts/live-release-confidence-journey.mjs +++ b/src/Web/StellaOps.Web/scripts/live-release-confidence-journey.mjs @@ -443,6 +443,76 @@ async function main() { }; }); + await recordStep(page, report, '02b-approval-detail-decision-cockpit', async (issues) => { + const detailUrl = buildScopedUrl('/releases/approvals/apr-001'); + + const verifyApprovalLink = async (tabName, linkName, expectedPath, options = {}) => { + await page.goto(detailUrl, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page, 2_000); + + if (tabName !== 'Overview') { + const tabButton = page.getByRole('button', { name: tabName, exact: true }).first(); + if (!(await tabButton.isVisible().catch(() => false))) { + issues.push(`approval detail is missing the "${tabName}" tab`); + return; + } + + await clickStable(page, tabButton); + await settle(page, 1_000); + } + + if (typeof options.beforeLinkClick === 'function') { + await options.beforeLinkClick(); + } + + const target = page.getByRole('link', { name: linkName, exact: true }).first(); + if (!(await target.isVisible().catch(() => false))) { + issues.push(`approval detail is missing the "${linkName}" handoff on ${tabName}`); + return; + } + + await clickStable(page, target); + await page.waitForURL((url) => url.pathname === expectedPath, { timeout: 20_000 }).catch(() => {}); + await settle(page, 1_500); + + const finalPath = new URL(page.url()).pathname; + if (finalPath !== expectedPath) { + issues.push(`approval detail "${linkName}" landed on ${page.url()} instead of ${expectedPath}`); + } + issues.push(...scopeIssues(page.url(), `approval detail ${tabName} -> ${linkName}`)); + }; + + await page.goto(detailUrl, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page, 2_000); + + await ensureHeading(page, /approval detail/i, issues, 'approval detail'); + const cockpitText = await bodyText(page); + if (/coming soon/i.test(cockpitText) || !/decision case-file|reachability|ops\/data|replay\/verify/i.test(cockpitText)) { + issues.push('approval detail is still rendering placeholder content instead of the decision cockpit'); + } + + await verifyApprovalLink('Reachability', 'Open Reachability Ingest Health', '/ops/operations/data-integrity/reachability-ingest'); + await verifyApprovalLink('Reachability', 'Open Env Detail', '/setup/topology/environments'); + await verifyApprovalLink('Ops/Data', 'Open Data Integrity', '/ops/operations/data-integrity'); + await verifyApprovalLink('Ops/Data', 'Open Integrations', '/setup/integrations'); + await verifyApprovalLink('Gates', 'Trigger SBOM Scan', '/ops/operations/data-integrity/scan-pipeline', { + beforeLinkClick: async () => { + await clickStable(page, page.getByRole('button', { name: 'Gate detail trace', exact: true }).first()); + await settle(page, 750); + }, + }); + + return { + detailUrl, + }; + }); + await recordStep(page, report, '03-decision-capsules-search-and-detail', async (issues) => { await page.goto(buildScopedUrl('/evidence/capsules'), { waitUntil: 'domcontentloaded', diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.spec.ts new file mode 100644 index 000000000..c7706e7a5 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.spec.ts @@ -0,0 +1,121 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { ApprovalDetailPageComponent } from './approval-detail-page.component'; +import { APPROVALS_ROUTES } from './approvals.routes'; +import { OPERATIONS_PATHS, dataIntegrityPath } from '../platform/ops/operations-paths'; + +describe('ApprovalDetailPageComponent', () => { + let component: ApprovalDetailPageComponent; + let fixture: ComponentFixture; + const scopeQueryParams = { + tenant: 'demo-prod', + regions: 'eu-west', + environments: 'prod', + timeWindow: '24h', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ApprovalDetailPageComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + params: of({ id: 'apr-001' }), + queryParamMap: of(convertToParamMap(scopeQueryParams)), + snapshot: { + paramMap: convertToParamMap({ id: 'apr-001' }), + queryParamMap: convertToParamMap(scopeQueryParams), + queryParams: scopeQueryParams, + }, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ApprovalDetailPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('loads the decision cockpit for approval detail routes', async () => { + const detailRoute = APPROVALS_ROUTES.find((route) => route.path === ':id'); + + expect(await detailRoute?.loadComponent?.()).toBe(ApprovalDetailPageComponent); + }); + + it('uses canonical reachability handoff routes', () => { + component.setActiveTab('reachability'); + fixture.detectChanges(); + + const hrefs = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('.footer-links a'), + ).map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href')); + + expect(hrefs.some((href) => href?.startsWith(dataIntegrityPath('reachability-ingest')))).toBeTrue(); + expect(hrefs.some((href) => href?.startsWith('/setup/topology/environments'))).toBeTrue(); + }); + + it('uses canonical ops/data handoff routes', () => { + component.setActiveTab('ops-data'); + fixture.detectChanges(); + + const hrefs = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('.footer-links a'), + ).map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href')); + + expect(hrefs.some((href) => href?.startsWith(OPERATIONS_PATHS.dataIntegrity))).toBeTrue(); + expect(hrefs.some((href) => href?.startsWith('/setup/integrations'))).toBeTrue(); + expect(hrefs.some((href) => href?.startsWith(OPERATIONS_PATHS.schedulerRuns))).toBeTrue(); + expect(hrefs.some((href) => href?.startsWith(OPERATIONS_PATHS.deadLetter))).toBeTrue(); + }); + + it('uses canonical gate fix links for reachability blockers', () => { + const reachabilityGate = component.gateTraceRows.find((row) => row.id === 'reachability'); + + expect(reachabilityGate?.fixLinks.map((link) => link.route)).toEqual([ + dataIntegrityPath('scan-pipeline'), + '/security/findings', + '/ops/policy/vex/exceptions', + OPERATIONS_PATHS.dataIntegrity, + ]); + }); + + it('preserves operator scope in decision context query params', () => { + expect(component.decisioningContextParams({ create: '1' })).toEqual( + jasmine.objectContaining({ + tenant: 'demo-prod', + regions: 'eu-west', + environments: 'prod', + timeWindow: '24h', + approvalId: 'apr-001', + create: '1', + }), + ); + }); + + it('preserves operator scope in approval handoff links', () => { + component.setActiveTab('ops-data'); + fixture.detectChanges(); + + const hrefs = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('.footer-links a'), + ).map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href') ?? ''); + + const dataIntegrityHref = hrefs.find((href) => href.startsWith(OPERATIONS_PATHS.dataIntegrity)); + const integrationsHref = hrefs.find((href) => href.startsWith('/setup/integrations')); + + expect(dataIntegrityHref).toContain('tenant=demo-prod'); + expect(dataIntegrityHref).toContain('regions=eu-west'); + expect(dataIntegrityHref).toContain('environments=prod'); + expect(dataIntegrityHref).toContain('timeWindow=24h'); + + expect(integrationsHref).toContain('tenant=demo-prod'); + expect(integrationsHref).toContain('regions=eu-west'); + expect(integrationsHref).toContain('environments=prod'); + expect(integrationsHref).toContain('timeWindow=24h'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts index 77c91a1e9..69cca50c2 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts @@ -1,10 +1,10 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, ParamMap, Router, RouterLink } from '@angular/router'; import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state'; -import { OPERATIONS_PATHS } from '../platform/ops/operations-paths'; +import { OPERATIONS_PATHS, dataIntegrityPath } from '../platform/ops/operations-paths'; type GateResult = 'PASS' | 'WARN' | 'BLOCK'; type HealthStatus = 'OK' | 'WARN' | 'FAIL'; @@ -97,7 +97,7 @@ interface HistoryEvent { template: `
- Back to Approvals + Back to Approvals

Approval Detail

@@ -232,7 +232,7 @@ interface HistoryEvent { @if (row.result === 'BLOCK') { } @@ -298,7 +298,7 @@ interface HistoryEvent { @@ -339,8 +339,8 @@ interface HistoryEvent { } @@ -398,10 +398,10 @@ interface HistoryEvent {
} @@ -420,8 +420,8 @@ interface HistoryEvent {

Signature status: DSSE signed, transparency log anchored, replay metadata present.

} @@ -447,7 +447,7 @@ interface HistoryEvent { } @@ -788,12 +788,16 @@ interface HistoryEvent { }) export class ApprovalDetailPageComponent implements OnInit { protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; + protected readonly dataIntegrityPath = dataIntegrityPath; private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + protected readonly integrationsRoute = '/setup/integrations'; + protected readonly topologyEnvironmentsRoute = '/setup/topology/environments'; readonly minDecisionReasonLength = 10; readonly activeTab = signal('overview'); readonly expandedGateId = signal(null); + readonly scopeQueryParams = signal>({}); readonly approval = signal({ id: 'apr-001', @@ -846,14 +850,14 @@ export class ApprovalDetailPageComponent implements OnInit { timestamp: '2026-02-19T18:11:00Z', evidenceAge: '2h 11m', fixLinks: [ - { label: 'Trigger SBOM Scan', route: '/platform-ops/data-integrity/scan-pipeline' }, + { label: 'Trigger SBOM Scan', route: dataIntegrityPath('scan-pipeline') }, { label: 'Open Finding', route: '/security/findings' }, { label: 'Request Exception', route: '/ops/policy/vex/exceptions', queryParams: this.decisioningContextParams({ create: '1' }), }, - { label: 'Open Data Integrity', route: '/platform-ops/data-integrity' }, + { label: 'Open Data Integrity', route: OPERATIONS_PATHS.dataIntegrity }, ], }, { @@ -983,6 +987,10 @@ export class ApprovalDetailPageComponent implements OnInit { this.approval.update((state) => ({ ...state, id })); } }); + + this.route.queryParamMap.subscribe((queryParamMap) => { + this.scopeQueryParams.set(this.mapQueryParams(queryParamMap)); + }); } setActiveTab(tab: ApprovalTabId): void { @@ -1050,17 +1058,34 @@ export class ApprovalDetailPageComponent implements OnInit { })); } + handoffQueryParams(extra: Record = {}): Record { + return { + ...this.scopeQueryParams(), + ...extra, + }; + } + decisioningContextParams(extra: Record = {}): Record { const approval = this.approval(); const returnTo = buildContextReturnTo(this.router, ['/releases/approvals', approval.id]); - return { + return this.handoffQueryParams({ approvalId: approval.id, releaseId: approval.bundleVersion, environment: approval.targetEnvironment, artifact: approval.bundleDigest, returnTo, ...extra, - }; + }); + } + + private mapQueryParams(queryParamMap: ParamMap): Record { + return queryParamMap.keys.reduce>((params, key) => { + const value = queryParamMap.get(key); + if (value !== null && value.length > 0) { + params[key] = value; + } + return params; + }, {}); } } diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approvals.routes.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approvals.routes.ts index 374ce2d81..fad7411f0 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approvals.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approvals.routes.ts @@ -27,16 +27,14 @@ export const APPROVALS_ROUTES: Routes = [ // A6-02 through A6-05 — Decision cockpit { path: ':id', - title: 'Approval Decision', + title: 'Approval Detail', data: { - breadcrumb: 'Approval Decision', + breadcrumb: 'Approval Detail', // Available tabs in the decision cockpit: // overview | gates | security | reachability | ops-data | evidence | replay | history decisionTabs: ['overview', 'gates', 'security', 'reachability', 'ops-data', 'evidence', 'replay', 'history'], }, loadComponent: () => - import('../release-orchestrator/approvals/approval-detail/approval-detail.component').then( - (m) => m.ApprovalDetailComponent - ), + import('./approval-detail-page.component').then((m) => m.ApprovalDetailPageComponent), }, ]; diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 0f0ec460c..e066a5abc 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -13,6 +13,7 @@ "src/app/core/auth/tenant-activation.service.spec.ts", "src/app/core/console/console-status.service.spec.ts", "src/app/features/change-trace/change-trace-viewer.component.spec.ts", + "src/app/features/approvals/approval-detail-page.component.spec.ts", "src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts", "src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts", "src/app/features/admin-notifications/components/channel-management.component.spec.ts",