feat(ui): ship release promotions cutover

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

@@ -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',

View File

@@ -213,4 +213,68 @@ describe('ReleaseDetailComponent live refresh contract', () => {
}
);
});
it('opens the canonical promotions wizard with release context and a return-to link', () => {
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.mode.set('run');
component.releaseId.set('run-4');
component.activeTab.set('gate-decision');
component.runManagedRelease.set({
id: 'run-4',
name: 'billing',
version: 'v4',
releaseType: 'standard',
gateStatus: 'warn',
evidencePosture: 'partial',
riskTier: 'high',
needsApproval: true,
blocked: false,
replayMismatch: false,
createdAt: '2026-02-20T12:00:00Z',
createdBy: 'system',
updatedAt: '2026-02-20T12:30:00Z',
lastActor: 'system',
} as any);
component.runDetail.set({
runId: 'run-4',
releaseId: 'rel-4',
releaseName: 'billing',
releaseSlug: 'billing',
releaseType: 'standard',
releaseVersionId: 'ver-4',
releaseVersionNumber: 4,
releaseVersionDigest: 'sha256:jkl',
lane: 'standard',
status: 'running',
outcome: 'in_progress',
targetEnvironment: 'prod',
targetRegion: 'eu-west',
scopeSummary: 'stage->prod',
requestedAt: '2026-02-20T12:00:00Z',
updatedAt: '2026-02-20T12:30:00Z',
needsApproval: true,
blockedByDataIntegrity: false,
correlationKey: 'corr-4',
statusRow: {
runStatus: 'running',
gateStatus: 'warn',
approvalStatus: 'pending',
dataTrustStatus: 'healthy',
},
});
component.openPromotionWizard();
expect(navigateSpy).toHaveBeenCalledWith(
['/releases/promotions/create'],
{
queryParams: {
releaseId: 'rel-4',
returnTo: '/releases/runs/run-4/gate-decision',
},
},
);
});
});

View File

@@ -0,0 +1,196 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Route, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { routes } from '../../app/app.routes';
import { APPROVAL_API } from '../../app/core/api/approval.client';
import { CreatePromotionComponent } from '../../app/features/promotions/create-promotion.component';
import { RELEASES_ROUTES } from '../../app/routes/releases.routes';
function resolveRedirect(route: Route | undefined, params: Record<string, string> = {}): string | undefined {
const redirect = route?.redirectTo;
if (typeof redirect === 'string') {
return redirect;
}
if (typeof redirect !== 'function') {
return undefined;
}
return TestBed.runInInjectionContext(() => {
const router = TestBed.inject(Router);
const target = redirect({
params,
queryParams: { releaseId: 'rel-007', scope: 'review' },
fragment: 'details',
} as never) as unknown;
return typeof target === 'string' ? target : router.serializeUrl(target as never);
});
}
describe('release promotions cutover contract', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideRouter([])],
});
});
it('mounts promotions as a canonical releases subtree and preserves queue aliases', () => {
const promotions = RELEASES_ROUTES.find((route) => route.path === 'promotions');
const queue = RELEASES_ROUTES.find((route) => route.path === 'promotion-queue');
const queueCreate = RELEASES_ROUTES.find((route) => route.path === 'promotion-queue/create');
const queueDetail = RELEASES_ROUTES.find((route) => route.path === 'promotion-queue/:promotionId');
expect(promotions?.loadChildren).toBeDefined();
expect(resolveRedirect(queue)).toBe('/releases/promotions?releaseId=rel-007&scope=review#details');
expect(resolveRedirect(queueCreate)).toBe('/releases/promotions/create?releaseId=rel-007&scope=review#details');
expect(resolveRedirect(queueDetail, { promotionId: 'apr-007' })).toBe(
'/releases/promotions/apr-007?releaseId=rel-007&scope=review#details',
);
});
it('retargets legacy release-control promotion bookmarks to canonical releases promotions pages', () => {
const releaseControl = routes.find((route) => route.path === 'release-control');
const children = releaseControl?.children ?? [];
expect(resolveRedirect(children.find((route) => route.path === 'promotions'))).toBe(
'/releases/promotions?releaseId=rel-007&scope=review#details',
);
expect(resolveRedirect(children.find((route) => route.path === 'promotions/create'))).toBe(
'/releases/promotions/create?releaseId=rel-007&scope=review#details',
);
expect(resolveRedirect(children.find((route) => route.path === 'promotions/:promotionId'), { promotionId: 'apr-007' })).toBe(
'/releases/promotions/apr-007?releaseId=rel-007&scope=review#details',
);
});
});
describe('CreatePromotionComponent release-context handoff', () => {
it('hydrates release context from query params and preloads target preview when target env is supplied', async () => {
const approvalApi = {
getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue(
of([
{ id: 'env-production', name: 'Production', tier: 'production' },
{ id: 'env-stage', name: 'Stage', tier: 'staging' },
]),
),
getPromotionPreview: jasmine.createSpy('getPromotionPreview').and.returnValue(
of({
releaseId: 'rel-007',
releaseName: 'API Gateway',
sourceEnvironment: 'stage',
targetEnvironment: 'production',
gateResults: [],
allGatesPassed: true,
requiredApprovers: 2,
estimatedDeployTime: 120,
warnings: [],
}),
),
submitPromotionRequest: jasmine.createSpy('submitPromotionRequest').and.returnValue(
of({
id: 'apr-007',
releaseId: 'rel-007',
releaseName: 'API Gateway',
releaseVersion: '2.1.0',
sourceEnvironment: 'stage',
targetEnvironment: 'production',
requestedBy: 'ops',
requestedAt: '2026-03-08T09:30:00Z',
urgency: 'normal',
justification: 'Promote',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-03-10T09:30:00Z',
}),
),
};
await TestBed.configureTestingModule({
imports: [CreatePromotionComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({
releaseId: 'rel-007',
targetEnvironmentId: 'env-production',
returnTo: '/releases/runs/run-007/gate-decision',
}),
},
},
},
{ provide: APPROVAL_API, useValue: approvalApi },
],
}).compileComponents();
const fixture = TestBed.createComponent(CreatePromotionComponent);
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.releaseId()).toBe('rel-007');
expect(component.returnTo()).toBe('/releases/runs/run-007/gate-decision');
expect(component.launchedFromReleaseContext()).toBeTrue();
expect(component.targetEnvironmentId()).toBe('env-production');
expect(component.activeStep()).toBe(4);
expect(approvalApi.getAvailableEnvironments).toHaveBeenCalledWith('rel-007');
expect(approvalApi.getPromotionPreview).toHaveBeenCalledWith('rel-007', 'env-production');
});
it('opens Decisioning Studio with a return-to link to the canonical promotion wizard', async () => {
const approvalApi = {
getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue(of([])),
getPromotionPreview: jasmine.createSpy('getPromotionPreview').and.returnValue(of(null)),
submitPromotionRequest: jasmine.createSpy('submitPromotionRequest').and.returnValue(of(null)),
};
await TestBed.configureTestingModule({
imports: [CreatePromotionComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({}),
},
},
},
{ provide: APPROVAL_API, useValue: approvalApi },
],
}).compileComponents();
const fixture = TestBed.createComponent(CreatePromotionComponent);
const component = fixture.componentInstance;
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
Object.defineProperty(router, 'url', {
configurable: true,
get: () =>
'/releases/promotions/create?releaseId=rel-123&targetEnvironmentId=env-production&returnTo=%2Freleases%2Fversions%2Frel-123%2Fgate-decision',
});
component.releaseId.set('rel-123');
component.targetEnvironmentId.set('env-production');
component.openDecisioningPreview();
expect(navigateSpy).toHaveBeenCalledWith(
['/ops/policy/gates/releases', 'rel-123'],
{
queryParams: {
releaseId: 'rel-123',
environment: 'env-production',
returnTo:
'/releases/promotions/create?releaseId=rel-123&targetEnvironmentId=env-production&returnTo=%2Freleases%2Fversions%2Frel-123%2Fgate-decision',
},
},
);
});
});

View File

@@ -0,0 +1,276 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
const operatorSession: StubAuthSession = {
subjectId: 'release-promotions-e2e-user',
tenant: 'tenant-default',
scopes: [
'admin',
'ui.read',
'release:read',
'release:write',
'release:publish',
'orch:read',
'orch:operate',
'policy:read',
'policy:review',
],
};
const mockConfig = {
authority: {
issuer: '/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: '/authority/connect/authorize',
tokenEndpoint: '/authority/connect/token',
logoutEndpoint: '/authority/connect/logout',
redirectUri: 'https://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
scope: 'openid profile email ui.read',
audience: '/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const approvalSummary = {
approvalId: 'apr-001',
releaseId: 'rel-001',
releaseName: 'API Gateway',
releaseVersion: '2.1.0',
sourceEnvironment: 'stage',
targetEnvironment: 'production',
requestedBy: 'alice',
requestedAt: '2026-03-08T08:30:00Z',
urgency: 'normal',
justification: 'Promote the verified API Gateway release to production.',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
blockers: [],
};
const approvalDetail = {
...approvalSummary,
gateResults: [
{
gateId: 'gate-policy',
gateName: 'Policy',
type: 'policy',
status: 'passed',
message: 'Policy checks passed.',
evaluatedAt: '2026-03-08T08:35:00Z',
},
],
actions: [],
approvers: [],
releaseComponents: [{ name: 'api-gateway', version: '2.1.0', digest: 'sha256:api-gateway' }],
};
const createdApproval = {
id: 'apr-new',
releaseId: 'rel-001',
releaseName: 'API Gateway',
releaseVersion: '2.1.0',
sourceEnvironment: 'stage',
targetEnvironment: 'production',
requestedBy: 'release-promotions-e2e-user',
requestedAt: '2026-03-08T09:45:00Z',
urgency: 'normal',
justification: 'Promote the API Gateway release to production after decisioning review.',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-03-10T09:45:00Z',
};
async function fulfillJson(route: Route, body: unknown, status = 200): Promise<void> {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
async function setupHarness(page: Page): Promise<void> {
await page.addInitScript((session) => {
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, operatorSession);
await page.route('**/api/**', (route) => fulfillJson(route, {}));
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/platform/i18n/*.json', (route) => fulfillJson(route, {}));
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/.well-known/openid-configuration', (route) =>
fulfillJson(route, {
issuer: 'https://127.0.0.1:4400/authority',
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
}),
);
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
await page.route('**/console/branding**', (route) =>
fulfillJson(route, {
tenantId: operatorSession.tenant,
appName: 'Stella Ops',
logoUrl: null,
cssVariables: {},
}),
);
await page.route('**/console/profile**', (route) =>
fulfillJson(route, {
subjectId: operatorSession.subjectId,
username: 'release-promotions-e2e',
displayName: 'Release Promotions E2E',
tenant: operatorSession.tenant,
roles: ['release-operator'],
scopes: operatorSession.scopes,
}),
);
await page.route('**/console/token/introspect**', (route) =>
fulfillJson(route, {
active: true,
tenant: operatorSession.tenant,
subject: operatorSession.subjectId,
scopes: operatorSession.scopes,
}),
);
await page.route('**/authority/console/tenants**', (route) =>
fulfillJson(route, {
tenants: [
{
tenantId: operatorSession.tenant,
displayName: 'Default Tenant',
isDefault: true,
isActive: true,
},
],
}),
);
await page.route('**/api/v2/context/regions**', (route) =>
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
);
await page.route('**/api/v2/context/environments**', (route) =>
fulfillJson(route, [
{
environmentId: 'production',
regionId: 'eu-west',
environmentType: 'prod',
displayName: 'Production',
sortOrder: 1,
enabled: true,
},
]),
);
await page.route('**/api/v2/context/preferences**', (route) =>
fulfillJson(route, {
tenantId: operatorSession.tenant,
actorId: operatorSession.subjectId,
regions: ['eu-west'],
environments: ['production'],
timeWindow: '24h',
stage: 'all',
updatedAt: '2026-03-08T07:00:00Z',
updatedBy: operatorSession.subjectId,
}),
);
await page.route(/\/api\/v2\/releases\/approvals(?:\?.*)?$/, (route) => fulfillJson(route, [approvalSummary]));
await page.route(/\/api\/v1\/approvals\/apr-001$/, (route) => fulfillJson(route, approvalDetail));
await page.route(/\/api\/v1\/approvals\/apr-new$/, (route) =>
fulfillJson(route, {
...approvalDetail,
...createdApproval,
gateResults: approvalDetail.gateResults,
}),
);
await page.route(/\/api\/v1\/release-orchestrator\/releases\/rel-001\/available-environments$/, (route) =>
fulfillJson(route, [
{ id: 'env-stage', name: 'Stage', tier: 'staging' },
{ id: 'env-production', name: 'Production', tier: 'production' },
]),
);
await page.route(/\/api\/v1\/release-orchestrator\/releases\/rel-001\/promotion-preview(?:\?.*)?$/, (route) =>
fulfillJson(route, {
releaseId: 'rel-001',
releaseName: 'API Gateway',
sourceEnvironment: 'stage',
targetEnvironment: 'production',
gateResults: [
{
gateId: 'gate-policy',
gateName: 'Policy',
type: 'policy',
status: 'passed',
message: 'Policy checks passed.',
evaluatedAt: '2026-03-08T09:40:00Z',
},
],
allGatesPassed: true,
requiredApprovers: 2,
estimatedDeployTime: 180,
warnings: [],
}),
);
await page.route(/\/api\/v1\/release-orchestrator\/releases\/rel-001\/promote$/, async (route) =>
fulfillJson(route, createdApproval, 201),
);
}
test.beforeEach(async ({ page }) => {
await setupHarness(page);
});
test('release overview surfaces the canonical promotions page', async ({ page }) => {
await page.goto('/releases/overview', { waitUntil: 'networkidle' });
await page.locator('.overview').getByRole('link', { name: 'Promotions' }).click();
await expect(page).toHaveURL(/\/releases\/promotions(?:\?.*)?$/);
await expect(page.getByRole('heading', { name: 'Promotions' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Create Promotion' })).toBeVisible();
});
test('legacy promotions create alias lands on the canonical wizard and submits a promotion request', async ({ page }) => {
await page.goto(
'/release-control/promotions/create?releaseId=rel-001&returnTo=%2Freleases%2Fruns%2Frun-001%2Fgate-decision',
{ waitUntil: 'networkidle' },
);
await expect(page).toHaveURL(/\/releases\/promotions\/create\?releaseId=rel-001/);
await expect(page.getByLabel('Release context handoff')).toContainText('rel-001');
await expect(page.getByRole('heading', { name: 'Select Region and Environment Path' })).toBeVisible();
await page.locator('#target-env').selectOption('env-production');
await expect(page.getByRole('heading', { name: 'Gate Preview' })).toBeVisible();
await expect(page.getByText('All gates passed')).toBeVisible();
await page.getByRole('button', { name: 'Next ->' }).click();
await expect(page.getByRole('heading', { name: 'Approval Context' })).toBeVisible();
await page.getByLabel('Justification').fill(
'Promote the API Gateway release to production after decisioning review.',
);
await page.getByRole('button', { name: 'Next ->' }).click();
await expect(page.getByRole('heading', { name: 'Launch Promotion' })).toBeVisible();
await page.getByRole('button', { name: 'Submit Promotion Request' }).click();
await expect(page).toHaveURL(/\/releases\/promotions\/apr-new(?:\?.*)?$/);
await expect(page.getByRole('heading', { name: 'API Gateway' })).toBeVisible();
await expect(page.getByText('pending', { exact: true })).toBeVisible();
});