feat(ui): ship release promotions cutover
This commit is contained in:
@@ -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.
|
||||||
22
docs/features/checked/web/release-promotions-cutover-ui.md
Normal file
22
docs/features/checked/web/release-promotions-cutover-ui.md
Normal 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`
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
51
docs/modules/ui/release-promotions-cutover/README.md
Normal file
51
docs/modules/ui/release-promotions-cutover/README.md
Normal 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`
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user