feat(ui): ship release promotions cutover
This commit is contained in:
@@ -316,8 +316,13 @@ export const routes: Routes = [
|
||||
{ path: 'runs', redirectTo: '/releases/runs', pathMatch: 'full' },
|
||||
{ path: 'bundles', redirectTo: '/releases/bundles', pathMatch: 'full' },
|
||||
{ path: 'bundles/create', redirectTo: '/releases/bundles/create', pathMatch: 'full' },
|
||||
{ path: 'promotions', redirectTo: '/releases/approvals', pathMatch: 'full' },
|
||||
{ path: 'promotions/create', redirectTo: '/releases/approvals', pathMatch: 'full' },
|
||||
{ path: 'promotions', redirectTo: preserveAppRedirect('/releases/promotions'), pathMatch: 'full' },
|
||||
{ path: 'promotions/create', redirectTo: preserveAppRedirect('/releases/promotions/create'), pathMatch: 'full' },
|
||||
{
|
||||
path: 'promotions/:promotionId',
|
||||
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{ path: 'environments', redirectTo: '/releases/environments', pathMatch: 'full' },
|
||||
{ path: 'regions', redirectTo: '/releases/environments', pathMatch: 'full' },
|
||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
PromotionPreview,
|
||||
TargetEnvironment,
|
||||
} from '../../core/api/approval.models';
|
||||
import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state';
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
@@ -27,7 +29,9 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
template: `
|
||||
<div class="create-promotion">
|
||||
<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>
|
||||
|
||||
<header class="create-promotion__header">
|
||||
@@ -37,6 +41,12 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
</p>
|
||||
</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">
|
||||
@for (step of steps; track step.number) {
|
||||
<div
|
||||
@@ -137,6 +147,17 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
} @else {
|
||||
<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>
|
||||
}
|
||||
@case (5) {
|
||||
@@ -225,6 +246,13 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link--button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.create-promotion__title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
@@ -329,6 +357,12 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.state-block--info {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.materialization-state {
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
@@ -452,6 +486,12 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.decisioning-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.create-promotion__nav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -498,7 +538,7 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CreatePromotionComponent {
|
||||
export class CreatePromotionComponent implements OnInit {
|
||||
private readonly api = inject(APPROVAL_API);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
@@ -518,6 +558,7 @@ export class CreatePromotionComponent {
|
||||
readonly loadingPreview = signal(false);
|
||||
readonly submitting = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly returnTo = signal<string | null>(null);
|
||||
|
||||
readonly steps: ReadonlyArray<{ number: Step; label: string }> = [
|
||||
{ number: 1, label: 'Identity' },
|
||||
@@ -565,6 +606,25 @@ export class CreatePromotionComponent {
|
||||
} as const;
|
||||
});
|
||||
|
||||
readonly launchedFromReleaseContext = computed(() => this.returnTo() !== null);
|
||||
|
||||
ngOnInit(): void {
|
||||
const releaseId = this.route.snapshot.queryParamMap.get('releaseId')?.trim() ?? '';
|
||||
const targetEnvironmentId = this.route.snapshot.queryParamMap.get('targetEnvironmentId')?.trim() ?? '';
|
||||
const returnTo = this.route.snapshot.queryParamMap.get('returnTo')?.trim() ?? '';
|
||||
|
||||
if (returnTo.length > 0) {
|
||||
this.returnTo.set(returnTo);
|
||||
}
|
||||
|
||||
if (!releaseId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.releaseId.set(releaseId);
|
||||
this.loadEnvironments(targetEnvironmentId || null);
|
||||
}
|
||||
|
||||
nextStep(): void {
|
||||
const current = this.activeStep();
|
||||
if (current < 6 && this.canAdvance(current)) {
|
||||
@@ -604,7 +664,7 @@ export class CreatePromotionComponent {
|
||||
);
|
||||
}
|
||||
|
||||
loadEnvironments(): void {
|
||||
loadEnvironments(preferredTargetEnvironmentId?: string | null): void {
|
||||
if (!this.releaseId().trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -623,6 +683,15 @@ export class CreatePromotionComponent {
|
||||
.subscribe((items) => {
|
||||
this.environments.set(items);
|
||||
this.loadingEnvironments.set(false);
|
||||
if (items.length > 0) {
|
||||
this.activeStep.set(2);
|
||||
}
|
||||
|
||||
if (preferredTargetEnvironmentId && items.some((item) => item.id === preferredTargetEnvironmentId)) {
|
||||
this.targetEnvironmentId.set(preferredTargetEnvironmentId);
|
||||
this.activeStep.set(4);
|
||||
this.loadPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -653,9 +722,45 @@ export class CreatePromotionComponent {
|
||||
.subscribe((preview) => {
|
||||
this.preview.set(preview);
|
||||
this.loadingPreview.set(false);
|
||||
if (preview) {
|
||||
this.activeStep.set(4);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openDecisioningPreview(): void {
|
||||
const releaseId = this.releaseId().trim();
|
||||
if (!releaseId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const returnTo = this.router.url.startsWith('/releases/promotions')
|
||||
? this.router.url
|
||||
: buildContextReturnTo(this.router, ['/releases', 'promotions', 'create'], {
|
||||
releaseId,
|
||||
targetEnvironmentId: this.targetEnvironmentId() || null,
|
||||
returnTo: this.returnTo(),
|
||||
});
|
||||
|
||||
void this.router.navigate(['/ops/policy/gates/releases', releaseId], {
|
||||
queryParams: {
|
||||
releaseId,
|
||||
environment: this.targetEnvironmentId() || null,
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
const target = this.returnTo();
|
||||
if (target) {
|
||||
void this.router.navigateByUrl(target);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.router.navigate(['../'], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit()) {
|
||||
return;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Promotions Routes
|
||||
* Sprint: SPRINT_20260218_010_FE_ui_v2_rewire_releases_promotions_run_timeline (R5-01 through R5-04)
|
||||
*
|
||||
* Bundle-version anchored promotions under /release-control/promotions:
|
||||
* Bundle-version anchored promotions under /releases/promotions:
|
||||
* '' — Promotions list (filtered by bundle, environment, status)
|
||||
* create — Create promotion wizard (selects bundle version + target environment)
|
||||
* :promotionId — Promotion detail with release context and run timeline
|
||||
|
||||
@@ -154,7 +154,7 @@ interface ReloadOptions {
|
||||
</div>
|
||||
<div class="actions">
|
||||
<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('security-inputs')">Security</button>
|
||||
<button type="button" class="primary" (click)="openTab('evidence')">Evidence</button>
|
||||
@@ -235,7 +235,7 @@ interface ReloadOptions {
|
||||
<ul>
|
||||
@for (check of preflightChecks(); track check.id) { <li>{{ check.label }}: <strong>{{ check.status }}</strong></li> }
|
||||
</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>
|
||||
<p><a [routerLink]="[detailBasePath(), releaseId(), 'security-inputs']">Open blockers</a></p>
|
||||
</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 {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
this.selectedTargets.update((cur) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { RouterLink } from '@angular/router';
|
||||
<a routerLink="/releases/runs">Release Runs</a>
|
||||
<a routerLink="/releases/approvals">Approvals Queue</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -759,7 +759,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
},
|
||||
{
|
||||
id: 'rel-approvals',
|
||||
label: 'Approvals & Promotions',
|
||||
label: 'Approvals',
|
||||
route: '/releases/approvals',
|
||||
icon: 'check-circle',
|
||||
badge: 0,
|
||||
@@ -770,6 +770,17 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
StellaOpsScopes.EXCEPTION_APPROVE,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rel-promotions',
|
||||
label: 'Promotions',
|
||||
route: '/releases/promotions',
|
||||
icon: 'git-merge',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
],
|
||||
},
|
||||
{ id: 'rel-hotfix-list', label: 'Hotfixes', route: '/releases/hotfixes', icon: 'zap' },
|
||||
{ id: 'rel-envs', label: 'Environments', route: '/releases/environments', icon: 'globe' },
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ const STORAGE_KEY = 'stellaops.sidebar.preferences';
|
||||
const DEFAULTS: SidebarPreferences = {
|
||||
sidebarCollapsed: false,
|
||||
collapsedGroups: [],
|
||||
collapsedSections: ['ops', 'setup'],
|
||||
collapsedSections: [],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
@@ -12,6 +12,30 @@ function redirectRunTab(runId: string, tab: string, queryParams: Record<string,
|
||||
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 = [
|
||||
{
|
||||
path: '',
|
||||
@@ -108,12 +132,32 @@ export const RELEASES_ROUTES: Routes = [
|
||||
data: { breadcrumb: 'Approvals', semanticObject: 'run' },
|
||||
loadChildren: () => import('../features/approvals/approvals.routes').then((m) => m.APPROVALS_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'promotions',
|
||||
title: 'Promotions',
|
||||
data: { breadcrumb: 'Promotions' },
|
||||
loadChildren: () => import('../features/promotions/promotions.routes').then((m) => m.PROMOTION_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'promotion-queue',
|
||||
title: 'Promotion Queue',
|
||||
data: { breadcrumb: 'Promotion Queue' },
|
||||
loadComponent: () =>
|
||||
import('../features/promotions/promotions-list.component').then((m) => m.PromotionsListComponent),
|
||||
title: 'Promotions',
|
||||
data: { breadcrumb: 'Promotions' },
|
||||
pathMatch: 'full',
|
||||
redirectTo: preserveReleasesRedirect('/releases/promotions'),
|
||||
},
|
||||
{
|
||||
path: 'promotion-queue/create',
|
||||
title: 'Create Promotion',
|
||||
data: { breadcrumb: 'Create Promotion' },
|
||||
pathMatch: 'full',
|
||||
redirectTo: preserveReleasesRedirect('/releases/promotions/create'),
|
||||
},
|
||||
{
|
||||
path: 'promotion-queue/:promotionId',
|
||||
title: 'Promotion Detail',
|
||||
data: { breadcrumb: 'Promotion Detail' },
|
||||
pathMatch: 'full',
|
||||
redirectTo: preserveReleasesRedirect('/releases/promotions/:promotionId'),
|
||||
},
|
||||
{
|
||||
path: 'hotfixes',
|
||||
|
||||
@@ -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