diff --git a/docs-archived/implplan/SPRINT_20260308_010_FE_release_promotions_cutover.md b/docs-archived/implplan/SPRINT_20260308_010_FE_release_promotions_cutover.md new file mode 100644 index 000000000..9acd9a0fc --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260308_010_FE_release_promotions_cutover.md @@ -0,0 +1,96 @@ +# Sprint 20260308_010_FE - Release Promotions Cutover + +## Topic & Scope +- Restore the dormant promotions workflow as a first-class `Releases` capability instead of leaving it split between orphaned components, queue aliases, and approval-only entry points. +- Make `/releases/promotions` the canonical owner route for promotion list, creation, and detail flows, with legacy `/release-control/promotions*` and `/releases/promotion-queue*` bookmarks preserved. +- Wire a real release-context handoff from the active release workbench into the promotion wizard so operators can start a usable promotion request from a release they are reviewing. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: focused Angular route/component tests, Playwright route and workflow verification, updated UI docs, archived sprint. + +## Dependencies & Concurrency +- Builds on the shipped IA and canonical route work already completed for `Releases`, Decisioning Studio, and contextual return-to flows. +- Safe to implement in parallel with unrelated Router or live-search work as long as edits stay within `src/Web/StellaOps.Web` and scoped UI docs. + +## Documentation Prerequisites +- `docs/modules/ui/README.md` +- `docs/modules/ui/implementation_plan.md` +- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md` +- `docs/modules/ui/policy-decisioning-studio/README.md` + +## Delivery Tracker + +### FE-RP-001 - Canonical promotions owner route and alias cutover +Status: DONE +Dependency: none +Owners: Developer / Implementer +Task description: +- Mount the existing promotions list, create, and detail workflow under the live `Releases` route tree as `/releases/promotions`. +- Repair stale legacy redirects so `/release-control/promotions*` and `/releases/promotion-queue*` land on the canonical promotions subtree without dropping query state. +- Surface the canonical path from live release navigation so the workflow is discoverable from the active shell. + +Completion criteria: +- [x] `routes/releases.routes.ts` mounts a canonical promotions subtree. +- [x] Legacy aliases redirect to the canonical subtree while preserving query params and fragments. +- [x] Live shell navigation points at `/releases/promotions`, not the stale queue alias. + +### FE-RP-002 - Release-context promotion request handoff +Status: DONE +Dependency: FE-RP-001 +Owners: Developer / Implementer +Task description: +- Complete the promotion request handoff from the active release workbench so the operator can leave a release or run context and enter the promotion wizard with prefilled identity and a deterministic return path. +- Merge the worthwhile behavior from the dropped promotion-request surface, especially decisioning preview handoff and contextual return-to routing, into the canonical promotion wizard instead of reviving a second product page. + +Completion criteria: +- [x] Active release workbench actions navigate into the canonical promotion wizard with release context. +- [x] The promotion wizard preloads release context and offers a Decisioning Studio deep link with a valid return-to contract. +- [x] Operator back-navigation and post-submit behavior remain usable and deterministic. + +### FE-RP-003 - Verification coverage for route cutover and usable workflow +Status: DONE +Dependency: FE-RP-002 +Owners: Developer / Implementer, Test Automation, QA +Task description: +- Add focused Angular tests for the canonical route tree, release-context handoff, and promotion wizard state hydration. +- Add Playwright coverage that proves legacy promotion aliases cut over correctly and that a real operator can reach the promotion wizard from the live Releases area. + +Completion criteria: +- [x] Targeted Angular tests cover the canonical promotions route contract and release-context handoff. +- [x] Playwright covers at least one live shell entry point plus one legacy alias. +- [x] The scoped test suite passes deterministically. + +### FE-RP-004 - Docs sync, archive, and shipped-feature note +Status: DONE +Dependency: FE-RP-003 +Owners: Developer / Implementer, Documentation author +Task description: +- Document the canonical promotions owner route, alias policy, and release-context handoff in the UI module docs. +- Record the tested behavior in a checked feature note, update the task board and implementation plan, then archive the sprint only after all delivery tasks are actually complete. + +Completion criteria: +- [x] Module docs describe the canonical promotions workflow and alias contract. +- [x] Checked-feature verification note records the actual commands and outcomes. +- [x] Sprint is archived with all tasks marked `DONE`. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-08 | Sprint created and moved to DOING for canonical release promotions cutover. | Implementer | +| 2026-03-08 | Mounted canonical `/releases/promotions` routes, repaired `/release-control/promotions*` and `/releases/promotion-queue*` aliases, and split shell surfacing into distinct `Approvals` and `Promotions` entries. | Implementer | +| 2026-03-08 | Wired release-context handoff from the active release workbench into the canonical promotion wizard and merged decisioning-preview behavior from the dropped promotion-request surface. | Implementer | +| 2026-03-08 | Verification passed: Angular targeted tests `2` files / `8` tests, Playwright `2` scenarios, and `npm run build` with existing bundle-budget warnings only. | Implementer | + +## Decisions & Risks +- The sprint restores one operator workflow, not the full legacy release-orchestrator shell. If deeper release dashboards or environment editors prove valuable later, they need their own bounded sprint. +- Promotion creation stays under `Releases`, while approval adjudication remains under `Releases > Approvals`; the UI must avoid collapsing those two surfaces back into one ambiguous queue. +- Route alias handling must preserve query params and fragments so release-context and bookmark flows remain deterministic. +- Browser verification initially exposed a live-shell compile blocker in `src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts`; the sidebar preference contract was repaired as part of this sprint because the releases shell could not boot otherwise. +- Verification commands: + - `npm run test -- --watch=false --include src/tests/releases/release-promotions-cutover.spec.ts --include src/tests/releases/release-detail.live-refresh.spec.ts` + - `npx playwright test --config playwright.config.ts tests/e2e/release-promotions-cutover.spec.ts --workers=1` + - `npm run build` + +## Next Checkpoints +- Route and navigation cutover complete with local tests. +- Release-context promotion handoff verified in browser. +- Sprint archived and committed locally without unrelated files. diff --git a/docs/features/checked/web/release-promotions-cutover-ui.md b/docs/features/checked/web/release-promotions-cutover-ui.md new file mode 100644 index 000000000..fe9453aca --- /dev/null +++ b/docs/features/checked/web/release-promotions-cutover-ui.md @@ -0,0 +1,22 @@ +# Release Promotions Cutover UI + +## Verified Scope +- Canonical `Releases` ownership for promotions list, create, and detail routes under `/releases/promotions`. +- Legacy `/release-control/promotions*` and `/releases/promotion-queue*` bookmarks redirect to the canonical promotions subtree with query-state preservation. +- Active release workbench handoff into the canonical promotion wizard with `releaseId` and `returnTo`. +- Promotion wizard release-context hydration, Decisioning Studio handoff, and successful submission into promotion detail. + +## Verification Commands +- `npm run test -- --watch=false --include src/tests/releases/release-promotions-cutover.spec.ts --include src/tests/releases/release-detail.live-refresh.spec.ts` +- `npx playwright test --config playwright.config.ts tests/e2e/release-promotions-cutover.spec.ts --workers=1` +- `npm run build` + +## Verification Results +- Angular: `2` files passed, `8` tests passed. +- Playwright: `2` scenarios passed. +- Build: passed. +- Build warnings: existing bundle-budget warnings only for the initial bundle and existing setup-wizard component stylesheet budgets. + +## Notes +- Browser verification initially exposed a live-shell compile error in `sidebar-preference.service.ts`; the service contract was repaired so the shell can boot and the promotions flow can be exercised in-browser. +- Verified on UTC: `2026-03-08T09:45:51.0108656Z` diff --git a/docs/modules/ui/README.md b/docs/modules/ui/README.md index e7ec22be2..f9409ba3a 100644 --- a/docs/modules/ui/README.md +++ b/docs/modules/ui/README.md @@ -9,6 +9,8 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runtime posture, and admin workflows. ## Latest updates (2026-03-08) +- Shipped the canonical `Releases > Promotions` cutover, including repaired `/release-control/promotions*` and `/releases/promotion-queue*` aliases, release-context promotion wizard handoff, and a usable create-to-detail flow. +- Added checked-feature verification for release promotions at `../../features/checked/web/release-promotions-cutover-ui.md`. - Preserved canonical `Ops > Platform Setup` leaf URLs so `regions-environments`, `promotion-paths`, `workflows-gates`, and `gate-profiles` no longer rewrite into `Setup > Topology` on direct entry or quick-link navigation. - Added checked-feature verification for canonical platform-setup route preservation at `../../features/checked/web/platform-setup-canonical-route-preservation-ui.md`. - Shipped the `Mission Control`, `Security`, and `Ops > Operations` security-leaves cutover, including canonical surfacing for alerts, activity, unknowns, and notifications plus repaired `/analyze/unknowns*` and `/notify` ownership. @@ -89,6 +91,7 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runt - ./topology-trust-administration/README.md - ./security-operations-leaves/README.md - ./platform-setup-canonical-route-preservation/README.md +- ./release-promotions-cutover/README.md - ./triage-explainability-workspace/README.md - ./workflow-visualization-replay/README.md - ./contextual-actions-patterns/README.md diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 08954125d..c36deba12 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -111,6 +111,10 @@ - [DONE] FE-PLATFORMSETUP-001 Reproduce and bound the canonical rewrite defect - [DONE] FE-PLATFORMSETUP-002 Preserve canonical platform-setup URLs in the Web router - [DONE] FE-PLATFORMSETUP-003 Add focused regression coverage and retest +- [DONE] FE-RP-001 Mount canonical `/releases/promotions` routes and preserve legacy promotion aliases +- [DONE] FE-RP-002 Wire release-context handoff into the canonical promotion wizard +- [DONE] FE-RP-003 Verify route cutover and usable promotion request workflow +- [DONE] FE-RP-004 Sync docs, archive the sprint, and record the shipped feature - [DONE] FE-PO-001 Freeze Operations overview taxonomy and submenu structure - [DONE] FE-PO-002 Overview page regrouping and blocking-card contract - [DONE] FE-PO-003 Legacy widget absorption matrix for Platform Ops diff --git a/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md b/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md index 590b3baca..17a416c6d 100644 --- a/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md +++ b/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md @@ -191,6 +191,10 @@ These branches probably contain valuable pieces, but the right home needs one mo - The issue is duplication between older release-orchestrator shells and the newer releases/evidence/setup IA. - Likely target: - `/releases`, `/evidence`, `/setup/topology`, and Decisioning Studio release-context entry points +- Notes: + - Shipped slice: `docs/modules/ui/release-promotions-cutover/README.md` + - Verified UI flow: `docs/features/checked/web/release-promotions-cutover-ui.md` + - Remaining review should focus on whether legacy release dashboards, environment editors, and evidence shells still add unique value beyond the active Releases and Evidence surfaces. ### 14. Evidence And Proof Exploration - Type: `merge` diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index 840de0c63..c1146f7d0 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -34,6 +34,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `docs/features/checked/web/topology-trust-administration-ui.md` - shipped verification note for canonical topology and trust setup shells, repaired settings/admin/platform aliases, and platform-setup handoffs. - `docs/features/checked/web/security-operations-leaves-ui.md` - shipped verification note for mission alerts/activity surfacing, unknowns route repair, notifications ownership, and legacy security alias cutover. - `docs/features/checked/web/platform-setup-canonical-route-preservation-ui.md` - shipped verification note for preserved `/ops/platform-setup/*` URLs during the shared setup/topology cutover. +- `docs/features/checked/web/release-promotions-cutover-ui.md` - shipped verification note for canonical release promotions routing, alias cutover, release-context wizard handoff, and end-to-end request submission. - `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract. - `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan. - `docs/modules/ui/offline-operations/README.md` - detailed owner-shell contract for Offline Kit, Feeds & Airgap, Evidence handoffs, and stale alias policy. @@ -42,6 +43,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `docs/modules/ui/topology-trust-administration/README.md` - canonical setup owner contract for topology inventory, trust administration, legacy trust redirects, and platform-setup handoffs. - `docs/modules/ui/security-operations-leaves/README.md` - canonical owner contract for mission alerts/activity, security unknowns, notifications, and stale `/analyze`/`/notify` handoffs. - `docs/modules/ui/platform-setup-canonical-route-preservation/README.md` - preserved route contract for canonical `/ops/platform-setup/*` leaves during the shared setup/topology cutover. +- `docs/modules/ui/release-promotions-cutover/README.md` - canonical promotions owner contract, alias rules, and release-context handoff for the Releases shell. - `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier. - `docs/modules/ui/workflow-visualization-replay/README.md` - detailed run-detail graph, timeline, replay, and evidence UX dossier. - `docs/modules/ui/contextual-actions-patterns/README.md` - shared placement contract for stray actions, pages, drawers, and tabs. diff --git a/docs/modules/ui/release-promotions-cutover/README.md b/docs/modules/ui/release-promotions-cutover/README.md new file mode 100644 index 000000000..1e28c1bfd --- /dev/null +++ b/docs/modules/ui/release-promotions-cutover/README.md @@ -0,0 +1,51 @@ +# Release Promotions Cutover + +## Purpose +- Make `Releases` own the promotion workflow instead of splitting it across stale queue aliases, orphaned promotion routes, and approval-only entry points. +- Keep approvals as the adjudication surface, but move promotion list, create, and detail flows under one canonical subtree. + +## Canonical Routes +- `/releases/promotions` +- `/releases/promotions/create` +- `/releases/promotions/:promotionId` + +## Alias Policy +- Preserve legacy `/release-control/promotions`, `/release-control/promotions/create`, and `/release-control/promotions/:promotionId` bookmarks. +- Preserve stale `/releases/promotion-queue`, `/releases/promotion-queue/create`, and `/releases/promotion-queue/:promotionId` bookmarks. +- Redirect aliases into the canonical `/releases/promotions*` subtree while preserving query params and fragments. + +## Shell Surfacing +- `Releases` sidebar now exposes separate `Approvals` and `Promotions` entries. +- `Releases > Overview` links to canonical `Promotions`. +- The active release workbench launches the promotion wizard directly instead of trapping the operator in a non-executable promote tab. + +## Release-Context Handoff +- The release workbench opens `/releases/promotions/create` with: + - `releaseId` + - `returnTo` +- The promotion wizard treats this as a first-class release-context launch: + - preloads environments for the supplied release + - advances directly to target selection once release identity is known + - keeps a return path back to the originating release or run context + - deep-links into Decisioning Studio with a return path back to the canonical promotion wizard + +## Merge Notes From Dropped Surfaces +- The dormant `PromotionRequestComponent` was not revived as a second page. +- Its worthwhile behavior was merged into the canonical wizard: + - contextual return-to handling + - decisioning preview handoff + - release-context launch semantics + +## Verification +- Angular tests cover: + - canonical promotions route ownership + - legacy alias redirects + - promotion wizard release-context hydration + - release workbench handoff into the canonical wizard +- Playwright covers: + - overview surfacing of the canonical promotions page + - legacy create alias redirect plus end-to-end promotion request submission + +## Related +- `docs/features/checked/web/release-promotions-cutover-ui.md` +- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md` diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index da507c6b9..699c95088 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -316,8 +316,13 @@ export const routes: Routes = [ { path: 'runs', redirectTo: '/releases/runs', pathMatch: 'full' }, { path: 'bundles', redirectTo: '/releases/bundles', pathMatch: 'full' }, { path: 'bundles/create', redirectTo: '/releases/bundles/create', pathMatch: 'full' }, - { path: 'promotions', redirectTo: '/releases/approvals', pathMatch: 'full' }, - { path: 'promotions/create', redirectTo: '/releases/approvals', pathMatch: 'full' }, + { path: 'promotions', redirectTo: preserveAppRedirect('/releases/promotions'), pathMatch: 'full' }, + { path: 'promotions/create', redirectTo: preserveAppRedirect('/releases/promotions/create'), pathMatch: 'full' }, + { + path: 'promotions/:promotionId', + redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'), + pathMatch: 'full', + }, { path: 'environments', redirectTo: '/releases/environments', pathMatch: 'full' }, { path: 'regions', redirectTo: '/releases/environments', pathMatch: 'full' }, { path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' }, diff --git a/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts b/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts index 7427afd2f..f3cde491e 100644 --- a/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts @@ -3,6 +3,7 @@ import { Component, computed, inject, + OnInit, signal, } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -16,6 +17,7 @@ import type { PromotionPreview, TargetEnvironment, } from '../../core/api/approval.models'; +import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state'; type Step = 1 | 2 | 3 | 4 | 5 | 6; @@ -27,7 +29,9 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6; template: `
@@ -37,6 +41,12 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;

+ @if (launchedFromReleaseContext()) { +
+ Promotion request launched from the active release workspace for {{ releaseId() }}. +
+ } +
@for (step of steps; track step.number) {
No preview loaded yet.

} + +
+ +
} @case (5) { @@ -225,6 +246,13 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6; text-decoration: none; } + .back-link--button { + background: none; + border: none; + padding: 0; + cursor: pointer; + } + .create-promotion__title { font-size: 1.5rem; font-weight: 600; @@ -329,6 +357,12 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6; font-size: 0.82rem; } + .state-block--info { + border-color: #bfdbfe; + background: #eff6ff; + color: #1d4ed8; + } + .materialization-state { border-radius: 8px; padding: 0.65rem 0.75rem; @@ -452,6 +486,12 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6; font-size: 0.82rem; } + .decisioning-actions { + display: flex; + justify-content: flex-end; + margin-top: 0.75rem; + } + .create-promotion__nav { display: flex; justify-content: flex-end; @@ -498,7 +538,7 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6; `, ], }) -export class CreatePromotionComponent { +export class CreatePromotionComponent implements OnInit { private readonly api = inject(APPROVAL_API); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); @@ -518,6 +558,7 @@ export class CreatePromotionComponent { readonly loadingPreview = signal(false); readonly submitting = signal(false); readonly error = signal(null); + readonly returnTo = signal(null); readonly steps: ReadonlyArray<{ number: Step; label: string }> = [ { number: 1, label: 'Identity' }, @@ -565,6 +606,25 @@ export class CreatePromotionComponent { } as const; }); + readonly launchedFromReleaseContext = computed(() => this.returnTo() !== null); + + ngOnInit(): void { + const releaseId = this.route.snapshot.queryParamMap.get('releaseId')?.trim() ?? ''; + const targetEnvironmentId = this.route.snapshot.queryParamMap.get('targetEnvironmentId')?.trim() ?? ''; + const returnTo = this.route.snapshot.queryParamMap.get('returnTo')?.trim() ?? ''; + + if (returnTo.length > 0) { + this.returnTo.set(returnTo); + } + + if (!releaseId) { + return; + } + + this.releaseId.set(releaseId); + this.loadEnvironments(targetEnvironmentId || null); + } + nextStep(): void { const current = this.activeStep(); if (current < 6 && this.canAdvance(current)) { @@ -604,7 +664,7 @@ export class CreatePromotionComponent { ); } - loadEnvironments(): void { + loadEnvironments(preferredTargetEnvironmentId?: string | null): void { if (!this.releaseId().trim()) { return; } @@ -623,6 +683,15 @@ export class CreatePromotionComponent { .subscribe((items) => { this.environments.set(items); this.loadingEnvironments.set(false); + if (items.length > 0) { + this.activeStep.set(2); + } + + if (preferredTargetEnvironmentId && items.some((item) => item.id === preferredTargetEnvironmentId)) { + this.targetEnvironmentId.set(preferredTargetEnvironmentId); + this.activeStep.set(4); + this.loadPreview(); + } }); } @@ -653,9 +722,45 @@ export class CreatePromotionComponent { .subscribe((preview) => { this.preview.set(preview); this.loadingPreview.set(false); + if (preview) { + this.activeStep.set(4); + } }); } + openDecisioningPreview(): void { + const releaseId = this.releaseId().trim(); + if (!releaseId) { + return; + } + + const returnTo = this.router.url.startsWith('/releases/promotions') + ? this.router.url + : buildContextReturnTo(this.router, ['/releases', 'promotions', 'create'], { + releaseId, + targetEnvironmentId: this.targetEnvironmentId() || null, + returnTo: this.returnTo(), + }); + + void this.router.navigate(['/ops/policy/gates/releases', releaseId], { + queryParams: { + releaseId, + environment: this.targetEnvironmentId() || null, + returnTo, + }, + }); + } + + goBack(): void { + const target = this.returnTo(); + if (target) { + void this.router.navigateByUrl(target); + return; + } + + void this.router.navigate(['../'], { relativeTo: this.route }); + } + submit(): void { if (!this.canSubmit()) { return; diff --git a/src/Web/StellaOps.Web/src/app/features/promotions/promotions.routes.ts b/src/Web/StellaOps.Web/src/app/features/promotions/promotions.routes.ts index a5fe1e9c3..3105ed662 100644 --- a/src/Web/StellaOps.Web/src/app/features/promotions/promotions.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/promotions/promotions.routes.ts @@ -2,7 +2,7 @@ * Promotions Routes * Sprint: SPRINT_20260218_010_FE_ui_v2_rewire_releases_promotions_run_timeline (R5-01 through R5-04) * - * Bundle-version anchored promotions under /release-control/promotions: + * Bundle-version anchored promotions under /releases/promotions: * '' — Promotions list (filtered by bundle, environment, status) * create — Create promotion wizard (selects bundle version + target environment) * :promotionId — Promotion detail with release context and run timeline diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts index c76bc6d8f..c453bcf71 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts @@ -154,7 +154,7 @@ interface ReloadOptions {
- + @@ -235,7 +235,7 @@ interface ReloadOptions {
    @for (check of preflightChecks(); track check.id) {
  • {{ check.label }}: {{ check.status }}
  • }
- +

Open blockers

@@ -641,6 +641,20 @@ export class ReleaseDetailComponent { }); } + openPromotionWizard(): void { + const returnTo = buildContextReturnTo( + this.router, + [this.detailBasePath(), this.releaseId(), this.activeTab()], + ); + + void this.router.navigate(['/releases/promotions/create'], { + queryParams: { + releaseId: this.releaseContextId(), + returnTo, + }, + }); + } + toggleTarget(targetId: string, event: Event): void { const checked = (event.target as HTMLInputElement).checked; this.selectedTargets.update((cur) => { 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 8f12f1e27..a5adbc648 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 @@ -18,7 +18,7 @@ import { RouterLink } from '@angular/router'; Release Runs Approvals Queue Hotfixes - Promotion Queue + Promotions Deployment History
diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index 20df8dd26..d74cb597c 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -759,7 +759,7 @@ export class AppSidebarComponent implements AfterViewInit { }, { id: 'rel-approvals', - label: 'Approvals & Promotions', + label: 'Approvals', route: '/releases/approvals', icon: 'check-circle', badge: 0, @@ -770,6 +770,17 @@ export class AppSidebarComponent implements AfterViewInit { StellaOpsScopes.EXCEPTION_APPROVE, ], }, + { + id: 'rel-promotions', + label: 'Promotions', + route: '/releases/promotions', + icon: 'git-merge', + requireAnyScope: [ + StellaOpsScopes.RELEASE_READ, + StellaOpsScopes.RELEASE_WRITE, + StellaOpsScopes.RELEASE_PUBLISH, + ], + }, { id: 'rel-hotfix-list', label: 'Hotfixes', route: '/releases/hotfixes', icon: 'zap' }, { id: 'rel-envs', label: 'Environments', route: '/releases/environments', icon: 'globe' }, { diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts index 2d6fe9863..01c1640da 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts @@ -11,7 +11,7 @@ const STORAGE_KEY = 'stellaops.sidebar.preferences'; const DEFAULTS: SidebarPreferences = { sidebarCollapsed: false, collapsedGroups: [], - collapsedSections: ['ops', 'setup'], + collapsedSections: [], }; @Injectable({ providedIn: 'root' }) diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index 0c53f127f..578318842 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -12,6 +12,30 @@ function redirectRunTab(runId: string, tab: string, queryParams: Record; + queryParams: Record; + fragment?: string | null; + }) => { + const router = inject(Router); + let targetPath = template; + + for (const [name, value] of Object.entries(params ?? {})) { + targetPath = targetPath.replaceAll(`:${name}`, encodeURIComponent(value)); + } + + const target = router.parseUrl(targetPath); + target.queryParams = { ...queryParams }; + target.fragment = fragment ?? null; + return target; + }; +} + export const RELEASES_ROUTES: Routes = [ { path: '', @@ -108,12 +132,32 @@ export const RELEASES_ROUTES: Routes = [ data: { breadcrumb: 'Approvals', semanticObject: 'run' }, loadChildren: () => import('../features/approvals/approvals.routes').then((m) => m.APPROVALS_ROUTES), }, + { + path: 'promotions', + title: 'Promotions', + data: { breadcrumb: 'Promotions' }, + loadChildren: () => import('../features/promotions/promotions.routes').then((m) => m.PROMOTION_ROUTES), + }, { path: 'promotion-queue', - title: 'Promotion Queue', - data: { breadcrumb: 'Promotion Queue' }, - loadComponent: () => - import('../features/promotions/promotions-list.component').then((m) => m.PromotionsListComponent), + title: 'Promotions', + data: { breadcrumb: 'Promotions' }, + pathMatch: 'full', + redirectTo: preserveReleasesRedirect('/releases/promotions'), + }, + { + path: 'promotion-queue/create', + title: 'Create Promotion', + data: { breadcrumb: 'Create Promotion' }, + pathMatch: 'full', + redirectTo: preserveReleasesRedirect('/releases/promotions/create'), + }, + { + path: 'promotion-queue/:promotionId', + title: 'Promotion Detail', + data: { breadcrumb: 'Promotion Detail' }, + pathMatch: 'full', + redirectTo: preserveReleasesRedirect('/releases/promotions/:promotionId'), }, { path: 'hotfixes', diff --git a/src/Web/StellaOps.Web/src/tests/releases/release-detail.live-refresh.spec.ts b/src/Web/StellaOps.Web/src/tests/releases/release-detail.live-refresh.spec.ts index 5c662119c..ae0437e0c 100644 --- a/src/Web/StellaOps.Web/src/tests/releases/release-detail.live-refresh.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/releases/release-detail.live-refresh.spec.ts @@ -213,4 +213,68 @@ describe('ReleaseDetailComponent live refresh contract', () => { } ); }); + + it('opens the canonical promotions wizard with release context and a return-to link', () => { + const router = TestBed.inject(Router); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + component.mode.set('run'); + component.releaseId.set('run-4'); + component.activeTab.set('gate-decision'); + component.runManagedRelease.set({ + id: 'run-4', + name: 'billing', + version: 'v4', + releaseType: 'standard', + gateStatus: 'warn', + evidencePosture: 'partial', + riskTier: 'high', + needsApproval: true, + blocked: false, + replayMismatch: false, + createdAt: '2026-02-20T12:00:00Z', + createdBy: 'system', + updatedAt: '2026-02-20T12:30:00Z', + lastActor: 'system', + } as any); + component.runDetail.set({ + runId: 'run-4', + releaseId: 'rel-4', + releaseName: 'billing', + releaseSlug: 'billing', + releaseType: 'standard', + releaseVersionId: 'ver-4', + releaseVersionNumber: 4, + releaseVersionDigest: 'sha256:jkl', + lane: 'standard', + status: 'running', + outcome: 'in_progress', + targetEnvironment: 'prod', + targetRegion: 'eu-west', + scopeSummary: 'stage->prod', + requestedAt: '2026-02-20T12:00:00Z', + updatedAt: '2026-02-20T12:30:00Z', + needsApproval: true, + blockedByDataIntegrity: false, + correlationKey: 'corr-4', + statusRow: { + runStatus: 'running', + gateStatus: 'warn', + approvalStatus: 'pending', + dataTrustStatus: 'healthy', + }, + }); + + component.openPromotionWizard(); + + expect(navigateSpy).toHaveBeenCalledWith( + ['/releases/promotions/create'], + { + queryParams: { + releaseId: 'rel-4', + returnTo: '/releases/runs/run-4/gate-decision', + }, + }, + ); + }); }); diff --git a/src/Web/StellaOps.Web/src/tests/releases/release-promotions-cutover.spec.ts b/src/Web/StellaOps.Web/src/tests/releases/release-promotions-cutover.spec.ts new file mode 100644 index 000000000..1b9a9fed2 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/releases/release-promotions-cutover.spec.ts @@ -0,0 +1,196 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Route, Router, convertToParamMap, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { routes } from '../../app/app.routes'; +import { APPROVAL_API } from '../../app/core/api/approval.client'; +import { CreatePromotionComponent } from '../../app/features/promotions/create-promotion.component'; +import { RELEASES_ROUTES } from '../../app/routes/releases.routes'; + +function resolveRedirect(route: Route | undefined, params: Record = {}): string | undefined { + const redirect = route?.redirectTo; + if (typeof redirect === 'string') { + return redirect; + } + + if (typeof redirect !== 'function') { + return undefined; + } + + return TestBed.runInInjectionContext(() => { + const router = TestBed.inject(Router); + const target = redirect({ + params, + queryParams: { releaseId: 'rel-007', scope: 'review' }, + fragment: 'details', + } as never) as unknown; + + return typeof target === 'string' ? target : router.serializeUrl(target as never); + }); +} + +describe('release promotions cutover contract', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter([])], + }); + }); + + it('mounts promotions as a canonical releases subtree and preserves queue aliases', () => { + const promotions = RELEASES_ROUTES.find((route) => route.path === 'promotions'); + const queue = RELEASES_ROUTES.find((route) => route.path === 'promotion-queue'); + const queueCreate = RELEASES_ROUTES.find((route) => route.path === 'promotion-queue/create'); + const queueDetail = RELEASES_ROUTES.find((route) => route.path === 'promotion-queue/:promotionId'); + + expect(promotions?.loadChildren).toBeDefined(); + expect(resolveRedirect(queue)).toBe('/releases/promotions?releaseId=rel-007&scope=review#details'); + expect(resolveRedirect(queueCreate)).toBe('/releases/promotions/create?releaseId=rel-007&scope=review#details'); + expect(resolveRedirect(queueDetail, { promotionId: 'apr-007' })).toBe( + '/releases/promotions/apr-007?releaseId=rel-007&scope=review#details', + ); + }); + + it('retargets legacy release-control promotion bookmarks to canonical releases promotions pages', () => { + const releaseControl = routes.find((route) => route.path === 'release-control'); + const children = releaseControl?.children ?? []; + + expect(resolveRedirect(children.find((route) => route.path === 'promotions'))).toBe( + '/releases/promotions?releaseId=rel-007&scope=review#details', + ); + expect(resolveRedirect(children.find((route) => route.path === 'promotions/create'))).toBe( + '/releases/promotions/create?releaseId=rel-007&scope=review#details', + ); + expect(resolveRedirect(children.find((route) => route.path === 'promotions/:promotionId'), { promotionId: 'apr-007' })).toBe( + '/releases/promotions/apr-007?releaseId=rel-007&scope=review#details', + ); + }); +}); + +describe('CreatePromotionComponent release-context handoff', () => { + it('hydrates release context from query params and preloads target preview when target env is supplied', async () => { + const approvalApi = { + getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue( + of([ + { id: 'env-production', name: 'Production', tier: 'production' }, + { id: 'env-stage', name: 'Stage', tier: 'staging' }, + ]), + ), + getPromotionPreview: jasmine.createSpy('getPromotionPreview').and.returnValue( + of({ + releaseId: 'rel-007', + releaseName: 'API Gateway', + sourceEnvironment: 'stage', + targetEnvironment: 'production', + gateResults: [], + allGatesPassed: true, + requiredApprovers: 2, + estimatedDeployTime: 120, + warnings: [], + }), + ), + submitPromotionRequest: jasmine.createSpy('submitPromotionRequest').and.returnValue( + of({ + id: 'apr-007', + releaseId: 'rel-007', + releaseName: 'API Gateway', + releaseVersion: '2.1.0', + sourceEnvironment: 'stage', + targetEnvironment: 'production', + requestedBy: 'ops', + requestedAt: '2026-03-08T09:30:00Z', + urgency: 'normal', + justification: 'Promote', + status: 'pending', + currentApprovals: 0, + requiredApprovals: 2, + gatesPassed: true, + scheduledTime: null, + expiresAt: '2026-03-10T09:30:00Z', + }), + ), + }; + + await TestBed.configureTestingModule({ + imports: [CreatePromotionComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ + releaseId: 'rel-007', + targetEnvironmentId: 'env-production', + returnTo: '/releases/runs/run-007/gate-decision', + }), + }, + }, + }, + { provide: APPROVAL_API, useValue: approvalApi }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(CreatePromotionComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + + expect(component.releaseId()).toBe('rel-007'); + expect(component.returnTo()).toBe('/releases/runs/run-007/gate-decision'); + expect(component.launchedFromReleaseContext()).toBeTrue(); + expect(component.targetEnvironmentId()).toBe('env-production'); + expect(component.activeStep()).toBe(4); + expect(approvalApi.getAvailableEnvironments).toHaveBeenCalledWith('rel-007'); + expect(approvalApi.getPromotionPreview).toHaveBeenCalledWith('rel-007', 'env-production'); + }); + + it('opens Decisioning Studio with a return-to link to the canonical promotion wizard', async () => { + const approvalApi = { + getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue(of([])), + getPromotionPreview: jasmine.createSpy('getPromotionPreview').and.returnValue(of(null)), + submitPromotionRequest: jasmine.createSpy('submitPromotionRequest').and.returnValue(of(null)), + }; + + await TestBed.configureTestingModule({ + imports: [CreatePromotionComponent], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({}), + }, + }, + }, + { provide: APPROVAL_API, useValue: approvalApi }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(CreatePromotionComponent); + const component = fixture.componentInstance; + const router = TestBed.inject(Router); + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + Object.defineProperty(router, 'url', { + configurable: true, + get: () => + '/releases/promotions/create?releaseId=rel-123&targetEnvironmentId=env-production&returnTo=%2Freleases%2Fversions%2Frel-123%2Fgate-decision', + }); + + component.releaseId.set('rel-123'); + component.targetEnvironmentId.set('env-production'); + + component.openDecisioningPreview(); + + expect(navigateSpy).toHaveBeenCalledWith( + ['/ops/policy/gates/releases', 'rel-123'], + { + queryParams: { + releaseId: 'rel-123', + environment: 'env-production', + returnTo: + '/releases/promotions/create?releaseId=rel-123&targetEnvironmentId=env-production&returnTo=%2Freleases%2Fversions%2Frel-123%2Fgate-decision', + }, + }, + ); + }); +}); diff --git a/src/Web/StellaOps.Web/tests/e2e/release-promotions-cutover.spec.ts b/src/Web/StellaOps.Web/tests/e2e/release-promotions-cutover.spec.ts new file mode 100644 index 000000000..df1679bdc --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/release-promotions-cutover.spec.ts @@ -0,0 +1,276 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +import type { StubAuthSession } from '../../src/app/testing/auth-fixtures'; + +const operatorSession: StubAuthSession = { + subjectId: 'release-promotions-e2e-user', + tenant: 'tenant-default', + scopes: [ + 'admin', + 'ui.read', + 'release:read', + 'release:write', + 'release:publish', + 'orch:read', + 'orch:operate', + 'policy:read', + 'policy:review', + ], +}; + +const mockConfig = { + authority: { + issuer: '/authority', + clientId: 'stella-ops-ui', + authorizeEndpoint: '/authority/connect/authorize', + tokenEndpoint: '/authority/connect/token', + logoutEndpoint: '/authority/connect/logout', + redirectUri: 'https://127.0.0.1:4400/auth/callback', + postLogoutRedirectUri: 'https://127.0.0.1:4400/', + scope: 'openid profile email ui.read', + audience: '/gateway', + dpopAlgorithms: ['ES256'], + refreshLeewaySeconds: 60, + }, + apiBaseUrls: { + authority: '/authority', + scanner: '/scanner', + policy: '/policy', + concelier: '/concelier', + attestor: '/attestor', + gateway: '/gateway', + }, + quickstartMode: true, + setup: 'complete', +}; + +const approvalSummary = { + approvalId: 'apr-001', + releaseId: 'rel-001', + releaseName: 'API Gateway', + releaseVersion: '2.1.0', + sourceEnvironment: 'stage', + targetEnvironment: 'production', + requestedBy: 'alice', + requestedAt: '2026-03-08T08:30:00Z', + urgency: 'normal', + justification: 'Promote the verified API Gateway release to production.', + status: 'pending', + currentApprovals: 0, + requiredApprovals: 2, + blockers: [], +}; + +const approvalDetail = { + ...approvalSummary, + gateResults: [ + { + gateId: 'gate-policy', + gateName: 'Policy', + type: 'policy', + status: 'passed', + message: 'Policy checks passed.', + evaluatedAt: '2026-03-08T08:35:00Z', + }, + ], + actions: [], + approvers: [], + releaseComponents: [{ name: 'api-gateway', version: '2.1.0', digest: 'sha256:api-gateway' }], +}; + +const createdApproval = { + id: 'apr-new', + releaseId: 'rel-001', + releaseName: 'API Gateway', + releaseVersion: '2.1.0', + sourceEnvironment: 'stage', + targetEnvironment: 'production', + requestedBy: 'release-promotions-e2e-user', + requestedAt: '2026-03-08T09:45:00Z', + urgency: 'normal', + justification: 'Promote the API Gateway release to production after decisioning review.', + status: 'pending', + currentApprovals: 0, + requiredApprovals: 2, + gatesPassed: true, + scheduledTime: null, + expiresAt: '2026-03-10T09:45:00Z', +}; + +async function fulfillJson(route: Route, body: unknown, status = 200): Promise { + await route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(body), + }); +} + +async function setupHarness(page: Page): Promise { + await page.addInitScript((session) => { + (window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session; + }, operatorSession); + + await page.route('**/api/**', (route) => fulfillJson(route, {})); + await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig)); + await page.route('**/platform/i18n/*.json', (route) => fulfillJson(route, {})); + await page.route('**/config.json', (route) => fulfillJson(route, mockConfig)); + await page.route('**/.well-known/openid-configuration', (route) => + fulfillJson(route, { + issuer: 'https://127.0.0.1:4400/authority', + authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize', + token_endpoint: 'https://127.0.0.1:4400/authority/connect/token', + jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + }), + ); + await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] })); + await page.route('**/console/branding**', (route) => + fulfillJson(route, { + tenantId: operatorSession.tenant, + appName: 'Stella Ops', + logoUrl: null, + cssVariables: {}, + }), + ); + await page.route('**/console/profile**', (route) => + fulfillJson(route, { + subjectId: operatorSession.subjectId, + username: 'release-promotions-e2e', + displayName: 'Release Promotions E2E', + tenant: operatorSession.tenant, + roles: ['release-operator'], + scopes: operatorSession.scopes, + }), + ); + await page.route('**/console/token/introspect**', (route) => + fulfillJson(route, { + active: true, + tenant: operatorSession.tenant, + subject: operatorSession.subjectId, + scopes: operatorSession.scopes, + }), + ); + await page.route('**/authority/console/tenants**', (route) => + fulfillJson(route, { + tenants: [ + { + tenantId: operatorSession.tenant, + displayName: 'Default Tenant', + isDefault: true, + isActive: true, + }, + ], + }), + ); + await page.route('**/api/v2/context/regions**', (route) => + fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]), + ); + await page.route('**/api/v2/context/environments**', (route) => + fulfillJson(route, [ + { + environmentId: 'production', + regionId: 'eu-west', + environmentType: 'prod', + displayName: 'Production', + sortOrder: 1, + enabled: true, + }, + ]), + ); + await page.route('**/api/v2/context/preferences**', (route) => + fulfillJson(route, { + tenantId: operatorSession.tenant, + actorId: operatorSession.subjectId, + regions: ['eu-west'], + environments: ['production'], + timeWindow: '24h', + stage: 'all', + updatedAt: '2026-03-08T07:00:00Z', + updatedBy: operatorSession.subjectId, + }), + ); + await page.route(/\/api\/v2\/releases\/approvals(?:\?.*)?$/, (route) => fulfillJson(route, [approvalSummary])); + await page.route(/\/api\/v1\/approvals\/apr-001$/, (route) => fulfillJson(route, approvalDetail)); + await page.route(/\/api\/v1\/approvals\/apr-new$/, (route) => + fulfillJson(route, { + ...approvalDetail, + ...createdApproval, + gateResults: approvalDetail.gateResults, + }), + ); + await page.route(/\/api\/v1\/release-orchestrator\/releases\/rel-001\/available-environments$/, (route) => + fulfillJson(route, [ + { id: 'env-stage', name: 'Stage', tier: 'staging' }, + { id: 'env-production', name: 'Production', tier: 'production' }, + ]), + ); + await page.route(/\/api\/v1\/release-orchestrator\/releases\/rel-001\/promotion-preview(?:\?.*)?$/, (route) => + fulfillJson(route, { + releaseId: 'rel-001', + releaseName: 'API Gateway', + sourceEnvironment: 'stage', + targetEnvironment: 'production', + gateResults: [ + { + gateId: 'gate-policy', + gateName: 'Policy', + type: 'policy', + status: 'passed', + message: 'Policy checks passed.', + evaluatedAt: '2026-03-08T09:40:00Z', + }, + ], + allGatesPassed: true, + requiredApprovers: 2, + estimatedDeployTime: 180, + warnings: [], + }), + ); + await page.route(/\/api\/v1\/release-orchestrator\/releases\/rel-001\/promote$/, async (route) => + fulfillJson(route, createdApproval, 201), + ); +} + +test.beforeEach(async ({ page }) => { + await setupHarness(page); +}); + +test('release overview surfaces the canonical promotions page', async ({ page }) => { + await page.goto('/releases/overview', { waitUntil: 'networkidle' }); + await page.locator('.overview').getByRole('link', { name: 'Promotions' }).click(); + + await expect(page).toHaveURL(/\/releases\/promotions(?:\?.*)?$/); + await expect(page.getByRole('heading', { name: 'Promotions' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Create Promotion' })).toBeVisible(); +}); + +test('legacy promotions create alias lands on the canonical wizard and submits a promotion request', async ({ page }) => { + await page.goto( + '/release-control/promotions/create?releaseId=rel-001&returnTo=%2Freleases%2Fruns%2Frun-001%2Fgate-decision', + { waitUntil: 'networkidle' }, + ); + + await expect(page).toHaveURL(/\/releases\/promotions\/create\?releaseId=rel-001/); + await expect(page.getByLabel('Release context handoff')).toContainText('rel-001'); + await expect(page.getByRole('heading', { name: 'Select Region and Environment Path' })).toBeVisible(); + + await page.locator('#target-env').selectOption('env-production'); + await expect(page.getByRole('heading', { name: 'Gate Preview' })).toBeVisible(); + await expect(page.getByText('All gates passed')).toBeVisible(); + + await page.getByRole('button', { name: 'Next ->' }).click(); + await expect(page.getByRole('heading', { name: 'Approval Context' })).toBeVisible(); + await page.getByLabel('Justification').fill( + 'Promote the API Gateway release to production after decisioning review.', + ); + + await page.getByRole('button', { name: 'Next ->' }).click(); + await expect(page.getByRole('heading', { name: 'Launch Promotion' })).toBeVisible(); + await page.getByRole('button', { name: 'Submit Promotion Request' }).click(); + + await expect(page).toHaveURL(/\/releases\/promotions\/apr-new(?:\?.*)?$/); + await expect(page.getByRole('heading', { name: 'API Gateway' })).toBeVisible(); + await expect(page.getByText('pending', { exact: true })).toBeVisible(); +});