feat(ui): ship release promotions cutover

This commit is contained in:
master
2026-03-08 11:54:57 +02:00
parent abbfe64bd7
commit e4779a430f
18 changed files with 912 additions and 15 deletions

View File

@@ -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.

View File

@@ -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`

View File

@@ -9,6 +9,8 @@
The Console presents operator dashboards for scans, policies, VEX evidence, runtime posture, and admin workflows. The Console presents operator dashboards for scans, policies, VEX evidence, runtime posture, and admin workflows.
## Latest updates (2026-03-08) ## 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. - 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`. - 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. - 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 - ./topology-trust-administration/README.md
- ./security-operations-leaves/README.md - ./security-operations-leaves/README.md
- ./platform-setup-canonical-route-preservation/README.md - ./platform-setup-canonical-route-preservation/README.md
- ./release-promotions-cutover/README.md
- ./triage-explainability-workspace/README.md - ./triage-explainability-workspace/README.md
- ./workflow-visualization-replay/README.md - ./workflow-visualization-replay/README.md
- ./contextual-actions-patterns/README.md - ./contextual-actions-patterns/README.md

View File

@@ -111,6 +111,10 @@
- [DONE] FE-PLATFORMSETUP-001 Reproduce and bound the canonical rewrite defect - [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-002 Preserve canonical platform-setup URLs in the Web router
- [DONE] FE-PLATFORMSETUP-003 Add focused regression coverage and retest - [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-001 Freeze Operations overview taxonomy and submenu structure
- [DONE] FE-PO-002 Overview page regrouping and blocking-card contract - [DONE] FE-PO-002 Overview page regrouping and blocking-card contract
- [DONE] FE-PO-003 Legacy widget absorption matrix for Platform Ops - [DONE] FE-PO-003 Legacy widget absorption matrix for Platform Ops

View File

@@ -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. - The issue is duplication between older release-orchestrator shells and the newer releases/evidence/setup IA.
- Likely target: - Likely target:
- `/releases`, `/evidence`, `/setup/topology`, and Decisioning Studio release-context entry points - `/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 ### 14. Evidence And Proof Exploration
- Type: `merge` - Type: `merge`

View File

@@ -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/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/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/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/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/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. - `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/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/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/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/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/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. - `docs/modules/ui/contextual-actions-patterns/README.md` - shared placement contract for stray actions, pages, drawers, and tabs.

View File

@@ -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`

View File

@@ -316,8 +316,13 @@ export const routes: Routes = [
{ path: 'runs', redirectTo: '/releases/runs', pathMatch: 'full' }, { path: 'runs', redirectTo: '/releases/runs', pathMatch: 'full' },
{ path: 'bundles', redirectTo: '/releases/bundles', pathMatch: 'full' }, { path: 'bundles', redirectTo: '/releases/bundles', pathMatch: 'full' },
{ path: 'bundles/create', redirectTo: '/releases/bundles/create', pathMatch: 'full' }, { path: 'bundles/create', redirectTo: '/releases/bundles/create', pathMatch: 'full' },
{ path: 'promotions', redirectTo: '/releases/approvals', pathMatch: 'full' }, { path: 'promotions', redirectTo: preserveAppRedirect('/releases/promotions'), pathMatch: 'full' },
{ path: 'promotions/create', redirectTo: '/releases/approvals', 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: 'environments', redirectTo: '/releases/environments', pathMatch: 'full' },
{ path: 'regions', redirectTo: '/releases/environments', pathMatch: 'full' }, { path: 'regions', redirectTo: '/releases/environments', pathMatch: 'full' },
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' }, { path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },

View File

@@ -3,6 +3,7 @@ import {
Component, Component,
computed, computed,
inject, inject,
OnInit,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@@ -16,6 +17,7 @@ import type {
PromotionPreview, PromotionPreview,
TargetEnvironment, TargetEnvironment,
} from '../../core/api/approval.models'; } from '../../core/api/approval.models';
import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state';
type Step = 1 | 2 | 3 | 4 | 5 | 6; type Step = 1 | 2 | 3 | 4 | 5 | 6;
@@ -27,7 +29,9 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
template: ` template: `
<div class="create-promotion"> <div class="create-promotion">
<nav class="create-promotion__back"> <nav class="create-promotion__back">
<a routerLink=".." class="back-link"><- Back to Promotions</a> <button type="button" class="back-link back-link--button" (click)="goBack()">
<- {{ launchedFromReleaseContext() ? 'Back to Release Context' : 'Back to Promotions' }}
</button>
</nav> </nav>
<header class="create-promotion__header"> <header class="create-promotion__header">
@@ -37,6 +41,12 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
</p> </p>
</header> </header>
@if (launchedFromReleaseContext()) {
<div class="state-block state-block--info" aria-label="Release context handoff">
Promotion request launched from the active release workspace for <code>{{ releaseId() }}</code>.
</div>
}
<div class="create-promotion__steps" role="list"> <div class="create-promotion__steps" role="list">
@for (step of steps; track step.number) { @for (step of steps; track step.number) {
<div <div
@@ -137,6 +147,17 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
} @else { } @else {
<p class="state-inline">No preview loaded yet.</p> <p class="state-inline">No preview loaded yet.</p>
} }
<div class="decisioning-actions">
<button
type="button"
class="btn-secondary"
(click)="openDecisioningPreview()"
[disabled]="!releaseId().trim()"
>
Open Decisioning Studio
</button>
</div>
</section> </section>
} }
@case (5) { @case (5) {
@@ -225,6 +246,13 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
text-decoration: none; text-decoration: none;
} }
.back-link--button {
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.create-promotion__title { .create-promotion__title {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
@@ -329,6 +357,12 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
font-size: 0.82rem; font-size: 0.82rem;
} }
.state-block--info {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.materialization-state { .materialization-state {
border-radius: 8px; border-radius: 8px;
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
@@ -452,6 +486,12 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
font-size: 0.82rem; font-size: 0.82rem;
} }
.decisioning-actions {
display: flex;
justify-content: flex-end;
margin-top: 0.75rem;
}
.create-promotion__nav { .create-promotion__nav {
display: flex; display: flex;
justify-content: flex-end; 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 api = inject(APPROVAL_API);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
@@ -518,6 +558,7 @@ export class CreatePromotionComponent {
readonly loadingPreview = signal(false); readonly loadingPreview = signal(false);
readonly submitting = signal(false); readonly submitting = signal(false);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly returnTo = signal<string | null>(null);
readonly steps: ReadonlyArray<{ number: Step; label: string }> = [ readonly steps: ReadonlyArray<{ number: Step; label: string }> = [
{ number: 1, label: 'Identity' }, { number: 1, label: 'Identity' },
@@ -565,6 +606,25 @@ export class CreatePromotionComponent {
} as const; } 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 { nextStep(): void {
const current = this.activeStep(); const current = this.activeStep();
if (current < 6 && this.canAdvance(current)) { if (current < 6 && this.canAdvance(current)) {
@@ -604,7 +664,7 @@ export class CreatePromotionComponent {
); );
} }
loadEnvironments(): void { loadEnvironments(preferredTargetEnvironmentId?: string | null): void {
if (!this.releaseId().trim()) { if (!this.releaseId().trim()) {
return; return;
} }
@@ -623,6 +683,15 @@ export class CreatePromotionComponent {
.subscribe((items) => { .subscribe((items) => {
this.environments.set(items); this.environments.set(items);
this.loadingEnvironments.set(false); 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) => { .subscribe((preview) => {
this.preview.set(preview); this.preview.set(preview);
this.loadingPreview.set(false); 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 { submit(): void {
if (!this.canSubmit()) { if (!this.canSubmit()) {
return; return;

View File

@@ -2,7 +2,7 @@
* Promotions Routes * Promotions Routes
* Sprint: SPRINT_20260218_010_FE_ui_v2_rewire_releases_promotions_run_timeline (R5-01 through R5-04) * 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) * '' — Promotions list (filtered by bundle, environment, status)
* create — Create promotion wizard (selects bundle version + target environment) * create — Create promotion wizard (selects bundle version + target environment)
* :promotionId — Promotion detail with release context and run timeline * :promotionId — Promotion detail with release context and run timeline

View File

@@ -154,7 +154,7 @@ interface ReloadOptions {
</div> </div>
<div class="actions"> <div class="actions">
<button type="button" (click)="openDecisioningStudio()">Decisioning</button> <button type="button" (click)="openDecisioningStudio()">Decisioning</button>
<button type="button" (click)="openTab('gate-decision')">Promote</button> <button type="button" (click)="openPromotionWizard()">Promote</button>
<button type="button" (click)="openTab('deployments')">Deploy</button> <button type="button" (click)="openTab('deployments')">Deploy</button>
<button type="button" (click)="openTab('security-inputs')">Security</button> <button type="button" (click)="openTab('security-inputs')">Security</button>
<button type="button" class="primary" (click)="openTab('evidence')">Evidence</button> <button type="button" class="primary" (click)="openTab('evidence')">Evidence</button>
@@ -235,7 +235,7 @@ interface ReloadOptions {
<ul> <ul>
@for (check of preflightChecks(); track check.id) { <li>{{ check.label }}: <strong>{{ check.status }}</strong></li> } @for (check of preflightChecks(); track check.id) { <li>{{ check.label }}: <strong>{{ check.status }}</strong></li> }
</ul> </ul>
<button type="button" class="primary" [disabled]="!canPromote()">Promote Release</button> <button type="button" class="primary" (click)="openPromotionWizard()">Request Promotion</button>
<button type="button" (click)="openDecisioningStudio()">Open Decisioning Studio</button> <button type="button" (click)="openDecisioningStudio()">Open Decisioning Studio</button>
<p><a [routerLink]="[detailBasePath(), releaseId(), 'security-inputs']">Open blockers</a></p> <p><a [routerLink]="[detailBasePath(), releaseId(), 'security-inputs']">Open blockers</a></p>
</article> </article>
@@ -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 { toggleTarget(targetId: string, event: Event): void {
const checked = (event.target as HTMLInputElement).checked; const checked = (event.target as HTMLInputElement).checked;
this.selectedTargets.update((cur) => { this.selectedTargets.update((cur) => {

View File

@@ -18,7 +18,7 @@ import { RouterLink } from '@angular/router';
<a routerLink="/releases/runs">Release Runs</a> <a routerLink="/releases/runs">Release Runs</a>
<a routerLink="/releases/approvals">Approvals Queue</a> <a routerLink="/releases/approvals">Approvals Queue</a>
<a routerLink="/releases/hotfixes">Hotfixes</a> <a routerLink="/releases/hotfixes">Hotfixes</a>
<a routerLink="/releases/promotion-queue">Promotion Queue</a> <a routerLink="/releases/promotions">Promotions</a>
<a routerLink="/releases/deployments">Deployment History</a> <a routerLink="/releases/deployments">Deployment History</a>
</div> </div>
</section> </section>

View File

@@ -759,7 +759,7 @@ export class AppSidebarComponent implements AfterViewInit {
}, },
{ {
id: 'rel-approvals', id: 'rel-approvals',
label: 'Approvals & Promotions', label: 'Approvals',
route: '/releases/approvals', route: '/releases/approvals',
icon: 'check-circle', icon: 'check-circle',
badge: 0, badge: 0,
@@ -770,6 +770,17 @@ export class AppSidebarComponent implements AfterViewInit {
StellaOpsScopes.EXCEPTION_APPROVE, 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-hotfix-list', label: 'Hotfixes', route: '/releases/hotfixes', icon: 'zap' },
{ id: 'rel-envs', label: 'Environments', route: '/releases/environments', icon: 'globe' }, { id: 'rel-envs', label: 'Environments', route: '/releases/environments', icon: 'globe' },
{ {

View File

@@ -11,7 +11,7 @@ const STORAGE_KEY = 'stellaops.sidebar.preferences';
const DEFAULTS: SidebarPreferences = { const DEFAULTS: SidebarPreferences = {
sidebarCollapsed: false, sidebarCollapsed: false,
collapsedGroups: [], collapsedGroups: [],
collapsedSections: ['ops', 'setup'], collapsedSections: [],
}; };
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -12,6 +12,30 @@ function redirectRunTab(runId: string, tab: string, queryParams: Record<string,
return target; return target;
} }
function preserveReleasesRedirect(template: string) {
return ({
params,
queryParams,
fragment,
}: {
params: Record<string, string>;
queryParams: Record<string, string>;
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 = [ export const RELEASES_ROUTES: Routes = [
{ {
path: '', path: '',
@@ -108,12 +132,32 @@ export const RELEASES_ROUTES: Routes = [
data: { breadcrumb: 'Approvals', semanticObject: 'run' }, data: { breadcrumb: 'Approvals', semanticObject: 'run' },
loadChildren: () => import('../features/approvals/approvals.routes').then((m) => m.APPROVALS_ROUTES), 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', path: 'promotion-queue',
title: 'Promotion Queue', title: 'Promotions',
data: { breadcrumb: 'Promotion Queue' }, data: { breadcrumb: 'Promotions' },
loadComponent: () => pathMatch: 'full',
import('../features/promotions/promotions-list.component').then((m) => m.PromotionsListComponent), 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', path: 'hotfixes',

View File

@@ -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',
},
},
);
});
}); });

View File

@@ -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, string> = {}): 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',
},
},
);
});
});

View File

@@ -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<void> {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
async function setupHarness(page: Page): Promise<void> {
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();
});