Preserve deployment evidence navigation scope
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# Sprint 20260310-025 - FE Releases Deployment Evidence Scope Preservation
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Preserve active tenant/region/environment/time-window scope through deployment-detail evidence and replay actions so release operators do not fall into stale evidence context after drilling into a deployment.
|
||||||
|
- Tighten the existing live releases deployment Playwright harness so this class of scope regression fails explicitly in future scratch iterations.
|
||||||
|
- Working directory: `src/Web/StellaOps.Web`.
|
||||||
|
- Allowed coordination edits: `docs/implplan/SPRINT_20260310_025_FE_releases_deployment_evidence_scope_preservation.md`.
|
||||||
|
- Expected evidence: focused Angular scope tests and a live releases deployment Playwright run with scoped evidence/replay links.
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Depends on the authenticated local stack being reachable through `https://stella-ops.local`.
|
||||||
|
- Safe parallelism: avoid unrelated search or topology edits while this release-detail iteration is in progress.
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `docs/qa/feature-checks/FLOW.md`
|
||||||
|
- `docs/implplan/SPRINT_20260308_026_FE_live_releases_deployments_route_and_action_repair.md`
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
### FE-DEPLOY-SCOPE-001 - Preserve scoped evidence and replay handoffs
|
||||||
|
Status: DONE
|
||||||
|
Dependency: none
|
||||||
|
Owners: QA, Developer, Architect
|
||||||
|
Task description:
|
||||||
|
- Update deployment-detail navigation so back-navigation, replay verify, evidence workspace, and proof-chain links all preserve the active shell scope instead of falling back to stale global context.
|
||||||
|
- Add focused regression tests and harden the live Playwright check so scope drift becomes a failing result.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Deployment detail evidence and proof links merge active query scope.
|
||||||
|
- [x] `returnTo` emitted by deployment detail preserves the active context query parameters.
|
||||||
|
- [x] Live releases deployment Playwright evidence/replay checks fail on scope drift and pass on the fixed stack.
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-03-10 | Sprint created after the live releases deployment sweep showed deployment evidence and proof-chain actions reopening under `eu-west/eu-stage/7d` instead of the active `us-east/stage` scope. | Developer |
|
||||||
|
| 2026-03-10 | Added focused deployment-detail scope regressions (`2/2` passing), rebuilt the web shell, synced the live bundle, and reran the Playwright releases deployment sweep cleanly with scoped replay/evidence/proof URLs and no remaining scope issues. | Developer |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Decision: preserve scope at the deployment-detail source by emitting scoped `returnTo` URLs and merged query params rather than relying on downstream evidence pages to guess the active context.
|
||||||
|
- Risk: similar release-detail surfaces may still encode incomplete `returnTo` state.
|
||||||
|
- Mitigation: strengthen each live action harness to assert scoped downstream URLs, not just page reachability.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- Run focused deployment-detail scope tests.
|
||||||
|
- Rerun the live releases deployment Playwright check and commit the fix.
|
||||||
@@ -19,6 +19,36 @@ const DETAIL_URL = `${BASE_URL}/releases/deployments/DEP-2026-050?tenant=demo-pr
|
|||||||
const STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
|
const STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
|
||||||
const REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
|
const REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
|
||||||
const RESULT_PATH = path.join(outputDirectory, 'live-releases-deployments-check.json');
|
const RESULT_PATH = path.join(outputDirectory, 'live-releases-deployments-check.json');
|
||||||
|
const EXPECTED_SCOPE = {
|
||||||
|
tenant: 'demo-prod',
|
||||||
|
regions: 'us-east',
|
||||||
|
environments: 'stage',
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectScopeIssues(url, expectedScope, label) {
|
||||||
|
const issues = [];
|
||||||
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
for (const [key, expectedValue] of Object.entries(expectedScope)) {
|
||||||
|
const actualValue = parsed.searchParams.get(key);
|
||||||
|
if (actualValue !== expectedValue) {
|
||||||
|
issues.push(`${label} expected ${key}=${expectedValue} but got ${actualValue ?? '(missing)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnTo = parsed.searchParams.get('returnTo');
|
||||||
|
if (returnTo) {
|
||||||
|
const parsedReturnTo = new URL(returnTo, BASE_URL);
|
||||||
|
for (const [key, expectedValue] of Object.entries(expectedScope)) {
|
||||||
|
const actualValue = parsedReturnTo.searchParams.get(key);
|
||||||
|
if (actualValue !== expectedValue) {
|
||||||
|
issues.push(`returnTo from ${label} expected ${key}=${expectedValue} but got ${actualValue ?? '(missing)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
async function seedAuthenticatedPage(browser, authReport) {
|
async function seedAuthenticatedPage(browser, authReport) {
|
||||||
const context = await createAuthenticatedContext(browser, authReport, {
|
const context = await createAuthenticatedContext(browser, authReport, {
|
||||||
@@ -72,6 +102,7 @@ async function main() {
|
|||||||
artifactDownloadSuggestedFilename: '',
|
artifactDownloadSuggestedFilename: '',
|
||||||
logsDownloadSuggestedFilename: '',
|
logsDownloadSuggestedFilename: '',
|
||||||
detailActionStatus: '',
|
detailActionStatus: '',
|
||||||
|
scopeIssues: [],
|
||||||
};
|
};
|
||||||
const headerActions = page.locator('.deployment-detail .header-actions button');
|
const headerActions = page.locator('.deployment-detail .header-actions button');
|
||||||
|
|
||||||
@@ -107,6 +138,11 @@ async function main() {
|
|||||||
result.proofChainsHref = (await page.locator('.rekor-link').getAttribute('href')) ?? '';
|
result.proofChainsHref = (await page.locator('.rekor-link').getAttribute('href')) ?? '';
|
||||||
await page.goto(new URL(result.proofChainsHref, BASE_URL).toString(), { waitUntil: 'networkidle', timeout: 30_000 });
|
await page.goto(new URL(result.proofChainsHref, BASE_URL).toString(), { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
result.proofChainsUrl = page.url();
|
result.proofChainsUrl = page.url();
|
||||||
|
result.scopeIssues.push(
|
||||||
|
...collectScopeIssues(result.replayUrl, EXPECTED_SCOPE, 'replayUrl'),
|
||||||
|
...collectScopeIssues(result.evidenceWorkspaceUrl, EXPECTED_SCOPE, 'evidenceWorkspaceUrl'),
|
||||||
|
...collectScopeIssues(result.proofChainsUrl, EXPECTED_SCOPE, 'proofChainsUrl'),
|
||||||
|
);
|
||||||
|
|
||||||
await page.goto(result.detailUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
await page.goto(result.detailUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||||
await page.getByRole('button', { name: 'Artifacts' }).click();
|
await page.getByRole('button', { name: 'Artifacts' }).click();
|
||||||
@@ -140,6 +176,10 @@ async function main() {
|
|||||||
writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||||
await context.close();
|
await context.close();
|
||||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
|
||||||
|
if (result.scopeIssues.length > 0) {
|
||||||
|
throw new Error(result.scopeIssues.join('; '));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { ActivatedRoute, Router, RouterLink, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { DeploymentDetailPageComponent } from '../../features/deployments/deployment-detail-page.component';
|
||||||
|
|
||||||
|
describe('Deployment detail scope-preserving links', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [DeploymentDetailPageComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
params: of({ deploymentId: 'DEP-2026-050' }),
|
||||||
|
snapshot: {
|
||||||
|
queryParamMap: convertToParamMap({
|
||||||
|
tenant: 'demo-prod',
|
||||||
|
regions: 'us-east',
|
||||||
|
region: 'us-east',
|
||||||
|
environments: 'stage',
|
||||||
|
environment: 'stage',
|
||||||
|
timeWindow: '7d',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks deployment evidence links to merge the active query scope', () => {
|
||||||
|
const fixture = TestBed.createComponent(DeploymentDetailPageComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.activeTab.set('evidence');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const backLink = fixture.debugElement.query(By.css('.back-link'))?.injector.get(RouterLink) ?? null;
|
||||||
|
const evidenceLink = fixture.debugElement.query(By.css('.evidence-info a'))?.injector.get(RouterLink) ?? null;
|
||||||
|
const proofLink = fixture.debugElement.query(By.css('.rekor-link'))?.injector.get(RouterLink) ?? null;
|
||||||
|
|
||||||
|
expect(backLink).toBeTruthy();
|
||||||
|
expect(evidenceLink).toBeTruthy();
|
||||||
|
expect(proofLink).toBeTruthy();
|
||||||
|
expect([backLink, evidenceLink, proofLink].every((link) => link?.queryParamsHandling === 'merge')).toBeTrue();
|
||||||
|
|
||||||
|
expect(evidenceLink?.queryParams?.['returnTo']).toContain('tenant=demo-prod');
|
||||||
|
expect(evidenceLink?.queryParams?.['returnTo']).toContain('regions=us-east');
|
||||||
|
expect(evidenceLink?.queryParams?.['returnTo']).toContain('environments=stage');
|
||||||
|
expect(evidenceLink?.queryParams?.['returnTo']).toContain('timeWindow=7d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves scope when opening replay verification', () => {
|
||||||
|
const fixture = TestBed.createComponent(DeploymentDetailPageComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
component.replayVerify();
|
||||||
|
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(
|
||||||
|
['/evidence/verify-replay'],
|
||||||
|
jasmine.objectContaining({
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
queryParams: jasmine.objectContaining({
|
||||||
|
releaseId: 'v1.2.5',
|
||||||
|
returnTo: jasmine.stringMatching(/tenant=demo-prod/),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit, E
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state';
|
||||||
|
|
||||||
interface WorkflowStep {
|
interface WorkflowStep {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,7 +37,7 @@ interface DeploymentArtifact {
|
|||||||
template: `
|
template: `
|
||||||
<div class="deployment-detail">
|
<div class="deployment-detail">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<a routerLink="/releases/deployments" class="back-link">← Back to Deployments</a>
|
<a routerLink="/releases/deployments" queryParamsHandling="merge" class="back-link">← Back to Deployments</a>
|
||||||
<div class="header-main">
|
<div class="header-main">
|
||||||
<div class="header-title-row">
|
<div class="header-title-row">
|
||||||
<h1 class="page-title">{{ deployment().id }}</h1>
|
<h1 class="page-title">{{ deployment().id }}</h1>
|
||||||
@@ -292,6 +293,7 @@ interface DeploymentArtifact {
|
|||||||
<a
|
<a
|
||||||
[routerLink]="['/evidence/capsules']"
|
[routerLink]="['/evidence/capsules']"
|
||||||
[queryParams]="{ evidenceId: deployment().evidenceId, returnTo: buildReturnToUrl() }"
|
[queryParams]="{ evidenceId: deployment().evidenceId, returnTo: buildReturnToUrl() }"
|
||||||
|
queryParamsHandling="merge"
|
||||||
>
|
>
|
||||||
Open evidence workspace
|
Open evidence workspace
|
||||||
</a>
|
</a>
|
||||||
@@ -314,6 +316,7 @@ interface DeploymentArtifact {
|
|||||||
<a
|
<a
|
||||||
[routerLink]="['/evidence/proofs']"
|
[routerLink]="['/evidence/proofs']"
|
||||||
[queryParams]="{ evidenceId: deployment().evidenceId, returnTo: buildReturnToUrl() }"
|
[queryParams]="{ evidenceId: deployment().evidenceId, returnTo: buildReturnToUrl() }"
|
||||||
|
queryParamsHandling="merge"
|
||||||
class="rekor-link"
|
class="rekor-link"
|
||||||
>
|
>
|
||||||
Open proof chains
|
Open proof chains
|
||||||
@@ -699,15 +702,32 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
|||||||
releaseId: this.deployment().releaseVersion,
|
releaseId: this.deployment().releaseVersion,
|
||||||
returnTo: this.buildReturnToUrl(),
|
returnTo: this.buildReturnToUrl(),
|
||||||
},
|
},
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
buildReturnToUrl(): string {
|
buildReturnToUrl(): string {
|
||||||
return this.router.serializeUrl(
|
return buildContextReturnTo(
|
||||||
this.router.createUrlTree(['/releases/deployments', this.deployment().id]),
|
this.router,
|
||||||
|
['/releases/deployments', this.deployment().id],
|
||||||
|
this.currentContextQueryParams(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private currentContextQueryParams(): Record<string, string | null> {
|
||||||
|
const queryParamMap = this.route.snapshot.queryParamMap;
|
||||||
|
return {
|
||||||
|
tenant: queryParamMap.get('tenant'),
|
||||||
|
tenantId: queryParamMap.get('tenantId'),
|
||||||
|
regions: queryParamMap.get('regions'),
|
||||||
|
region: queryParamMap.get('region'),
|
||||||
|
environments: queryParamMap.get('environments'),
|
||||||
|
environment: queryParamMap.get('environment'),
|
||||||
|
timeWindow: queryParamMap.get('timeWindow'),
|
||||||
|
stage: queryParamMap.get('stage'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private buildArtifactPayload(artifact: DeploymentArtifact): {
|
private buildArtifactPayload(artifact: DeploymentArtifact): {
|
||||||
content: string;
|
content: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user