feat(ui): ship evidence capsules cutover
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
# Sprint 20260308_011_FE - Evidence Capsules Canonical Cutover
|
||||
|
||||
## Topic & Scope
|
||||
- Restore the usable decision-capsule workflow under the canonical Evidence shell instead of leaving it split between working components and dead legacy paths.
|
||||
- Make `/evidence/capsules` and `/evidence/capsules/:capsuleId` the only operator-facing owner routes for evidence-pack browsing, while preserving stale `/evidence-packs*` bookmarks.
|
||||
- Complete the cross-shell handoffs so AI Runs and release/evidence contexts deep-link into capsules with deterministic return-to behavior and an actionable related-run jump.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Expected evidence: focused Angular route/component tests, browser verification, updated UI docs, archived sprint.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Builds on the shipped Evidence shell, workflow-visualization run workspace, and contextual route-state utilities already present in `src/Web/StellaOps.Web`.
|
||||
- Safe to implement in parallel with unrelated backend or Router work as long as edits stay inside `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/platform/architecture-overview.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-EC-001 - Canonical capsule owner route and alias repair
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer / Implementer
|
||||
Task description:
|
||||
- Preserve the current Evidence shell as the owner of decision capsules and repair the stale route contract that still points live components at `/evidence-packs/:id`.
|
||||
- Add bookmark-safe alias handling for legacy evidence-pack paths so operators and old links land on the canonical capsule detail route without dropping query state.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Live components navigate to `/evidence/capsules` and `/evidence/capsules/:capsuleId`.
|
||||
- [x] Legacy `/evidence-packs` aliases redirect to canonical capsule routes while preserving query params and fragments.
|
||||
- [x] The capsule list/detail flow is discoverable and usable from the live Evidence shell.
|
||||
|
||||
### FE-EC-002 - Cross-shell handoffs and related-run navigation
|
||||
Status: DONE
|
||||
Dependency: FE-EC-001
|
||||
Owners: Developer / Implementer
|
||||
Task description:
|
||||
- Complete the worthwhile behavior from the dropped evidence/proof surfaces by wiring cross-shell entry points into the canonical capsule detail route with deterministic return-to context.
|
||||
- Ensure the capsule viewer can jump to the related owner workflow instead of the stale AI-run-only path, using the active Releases run workspace when the pack represents release evidence.
|
||||
|
||||
Completion criteria:
|
||||
- [x] AI Runs and other current entry points deep-link into canonical capsule detail routes with a valid return-to contract.
|
||||
- [x] Capsule detail provides a usable back action to the originating context or canonical capsule list.
|
||||
- [x] Related-run navigation lands on a live owner route instead of a stale or orphaned path.
|
||||
|
||||
### FE-EC-003 - Verification coverage for the cutover
|
||||
Status: DONE
|
||||
Dependency: FE-EC-002
|
||||
Owners: Developer / Implementer, Test Automation, QA
|
||||
Task description:
|
||||
- Add focused Angular tests for alias handling, canonical route hydration, and the related-run / return-to navigation behavior.
|
||||
- Add browser verification that proves both the live capsule flow and a legacy bookmark cut over correctly.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Targeted Angular tests cover the canonical capsule route contract and navigation behavior.
|
||||
- [x] Browser verification covers at least one live entry point plus one legacy alias.
|
||||
- [x] The scoped test suite passes deterministically.
|
||||
|
||||
### FE-EC-004 - Docs sync, archive, and shipped-feature note
|
||||
Status: DONE
|
||||
Dependency: FE-EC-003
|
||||
Owners: Developer / Implementer, Documentation author
|
||||
Task description:
|
||||
- Document the canonical capsule owner route, alias rules, and cross-shell handoff behavior in the UI module docs.
|
||||
- Record the verified behavior in a checked-feature note, update the task board and implementation plan, then archive the sprint only after the delivery tasks are actually complete.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Module docs describe the canonical capsule 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 the canonical evidence capsules cutover. | Implementer |
|
||||
| 2026-03-08 | Repaired the canonical Decision Capsule list and detail routes, added bookmark-safe `/evidence-packs*` aliases, and switched live capsule navigation away from the stale pack paths. | Implementer |
|
||||
| 2026-03-08 | Completed cross-shell handoffs from AI Runs and release evidence so capsule detail pages preserve `returnTo` context and open the live owner workspace for related runs. | Implementer |
|
||||
| 2026-03-08 | Verification passed: Angular targeted tests `1` file / `7` tests, Playwright `2` scenarios, and `npm run build` with existing bundle-budget warnings only. | Implementer |
|
||||
|
||||
## Decisions & Risks
|
||||
- This sprint is intentionally bounded to the canonical capsule list/detail flow plus handoffs. Broader proof-chain or evidence-thread restoration should land in separate evidence-domain sprints.
|
||||
- Evidence packs come from more than one product area; related-run navigation must prefer live owner routes and avoid reintroducing dead `/evidence-packs` or orphaned `/ai-runs` paths.
|
||||
- Route alias handling must preserve query params and fragments so release-context and AI-run bookmarks remain deterministic.
|
||||
- Browser verification initially failed because the release-run evidence workspace fixture returned the base run detail object for sibling endpoints like `/approvals` and `/deployments`; the E2E harness was corrected to return the real per-endpoint shapes instead of weakening the assertion surface.
|
||||
- Verification commands:
|
||||
- `npm run test -- --watch=false --include src/tests/evidence/evidence-capsules-cutover.spec.ts`
|
||||
- `npx playwright test --config playwright.config.ts tests/e2e/evidence-capsules-cutover.spec.ts --workers=1`
|
||||
- `npm run build`
|
||||
|
||||
## Next Checkpoints
|
||||
- Canonical route and alias cutover complete with focused tests.
|
||||
- Cross-shell handoffs verified in browser.
|
||||
- Sprint archived and committed locally without unrelated files.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Evidence Capsules Canonical Cutover UI
|
||||
|
||||
## Module
|
||||
Web
|
||||
|
||||
## Status
|
||||
VERIFIED
|
||||
|
||||
## Description
|
||||
Shipped the canonical Decision Capsule flow under `Evidence`, repaired stale `/evidence-packs*` bookmarks, and completed the cross-shell handoffs so AI Runs and release evidence can open capsule detail pages and return to their live owner workspaces without dead ends.
|
||||
|
||||
## Implementation Details
|
||||
- **Feature directories**:
|
||||
- `src/Web/StellaOps.Web/src/app/features/evidence-pack/`
|
||||
- `src/Web/StellaOps.Web/src/app/features/ai-runs/`
|
||||
- `src/Web/StellaOps.Web/src/app/features/workflow-visualization/`
|
||||
- **Primary components**:
|
||||
- `evidence-pack-list` (`src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts`)
|
||||
- `evidence-pack-viewer` (`src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts`)
|
||||
- `ai-run-viewer` (`src/Web/StellaOps.Web/src/app/features/ai-runs/ai-run-viewer.component.ts`)
|
||||
- **Canonical routes**:
|
||||
- `/evidence/capsules`
|
||||
- `/evidence/capsules/:capsuleId`
|
||||
- **Legacy aliases**:
|
||||
- `/evidence-packs`
|
||||
- `/evidence-packs/:capsuleId`
|
||||
- **Secondary entry points**:
|
||||
- `Ops > Operations > AI Runs`
|
||||
- `Releases > Runs > Evidence`
|
||||
|
||||
## E2E Test Plan
|
||||
- **Setup**:
|
||||
- [x] Start the local Angular test server with `npm run serve:test`.
|
||||
- [x] Use a test session with ops, release, policy, and signer scopes.
|
||||
- **Core verification**:
|
||||
- [x] Verify AI Run detail opens canonical Decision Capsule detail with a usable back action.
|
||||
- [x] Verify Decision Capsule detail opens the live related-run workspace, not a stale route.
|
||||
- **Legacy verification**:
|
||||
- [x] Verify `/evidence-packs/:capsuleId` bookmarks land on `/evidence/capsules/:capsuleId`.
|
||||
- [x] Verify the legacy bookmark can continue into the canonical release evidence workspace.
|
||||
|
||||
## Verification
|
||||
- Run:
|
||||
- `npm run test -- --watch=false --include src/tests/evidence/evidence-capsules-cutover.spec.ts`
|
||||
- `npx playwright test --config playwright.config.ts tests/e2e/evidence-capsules-cutover.spec.ts --workers=1`
|
||||
- `npm run build`
|
||||
- Tier 0 (source): pass
|
||||
- Tier 1 (build/tests): pass
|
||||
- Tier 2 (behavior): pass
|
||||
- Notes:
|
||||
- Angular targeted tests passed: `1` file, `7` tests.
|
||||
- Playwright passed: `2` scenarios.
|
||||
- Production build passed; existing bundle-budget warnings remain unchanged from the baseline.
|
||||
- Verified on (UTC): 2026-03-08T10:37:03Z
|
||||
@@ -115,6 +115,10 @@
|
||||
- [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-EC-001 Repair canonical capsule ownership and preserve `/evidence-packs*` aliases
|
||||
- [DONE] FE-EC-002 Complete cross-shell capsule handoffs and related-run navigation
|
||||
- [DONE] FE-EC-003 Verify route cutover and usable capsule workflow
|
||||
- [DONE] FE-EC-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-002 Overview page regrouping and blocking-card contract
|
||||
- [DONE] FE-PO-003 Legacy widget absorption matrix for Platform Ops
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Evidence Capsules Canonical Cutover
|
||||
|
||||
## Purpose
|
||||
- Keep Decision Capsules owned by the live `Evidence` shell instead of splitting them across canonical routes, stale legacy bookmarks, and context-specific dead ends.
|
||||
- Finish the usable workflow, not just the references: list, detail, back-navigation, related-run handoff, and bookmark repair all need to work for operators.
|
||||
|
||||
## Canonical Routes
|
||||
- `/evidence/capsules`
|
||||
- `/evidence/capsules/:capsuleId`
|
||||
|
||||
## Alias Policy
|
||||
- Preserve `/evidence-packs`
|
||||
- Preserve `/evidence-packs/:capsuleId`
|
||||
- Redirect aliases into the canonical `/evidence/capsules*` subtree while preserving query params and fragments.
|
||||
|
||||
## Shell Ownership
|
||||
- `Evidence` remains the only operator-facing owner of Decision Capsule browsing.
|
||||
- The capsule list uses Decision Capsule terminology consistently and opens detail pages inside the canonical Evidence shell.
|
||||
- Capsule detail exposes a back action that prefers the originating context and falls back to the canonical capsule list.
|
||||
|
||||
## Cross-Shell Handoffs
|
||||
- `Ops > Operations > AI Runs` opens canonical capsule detail routes with a deterministic `returnTo` contract.
|
||||
- Capsule detail routes related AI-generated evidence back into `/ops/operations/ai-runs/:runId`.
|
||||
- Capsule detail routes release evidence into `/releases/runs/:runId/evidence`.
|
||||
- Related-run handoffs always preserve a valid return path back to the canonical capsule detail page.
|
||||
|
||||
## Merge Notes From Dropped Surfaces
|
||||
- The stale `/evidence-packs*` surface was not preserved as a second owner route.
|
||||
- The useful behavior from the half-wired pack flow was merged into the canonical Evidence shell:
|
||||
- bookmark-safe alias repair
|
||||
- context-aware back navigation
|
||||
- live owner-route jumps for AI and release contexts
|
||||
|
||||
## Verification
|
||||
- Angular tests cover:
|
||||
- canonical capsule route ownership
|
||||
- legacy alias redirects
|
||||
- list/detail navigation behavior
|
||||
- capsule viewer `returnTo` and related-run handoffs
|
||||
- Playwright covers:
|
||||
- a live AI Runs entry point into capsule detail and back
|
||||
- a legacy `/evidence-packs/:capsuleId` bookmark that cuts over into the live release evidence workspace
|
||||
|
||||
## Related
|
||||
- `docs/features/checked/web/evidence-capsules-canonical-cutover-ui.md`
|
||||
- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`
|
||||
@@ -35,6 +35,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
||||
- `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/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/features/checked/web/evidence-capsules-canonical-cutover-ui.md` - shipped verification note for canonical Evidence-owned capsule routes, `/evidence-packs*` bookmark repair, and AI/release context handoffs.
|
||||
- `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/offline-operations/README.md` - detailed owner-shell contract for Offline Kit, Feeds & Airgap, Evidence handoffs, and stale alias policy.
|
||||
@@ -44,6 +45,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
||||
- `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/release-promotions-cutover/README.md` - canonical promotions owner contract, alias rules, and release-context handoff for the Releases shell.
|
||||
- `docs/modules/ui/evidence-capsules-canonical-cutover/README.md` - canonical Evidence owner contract for Decision Capsule list/detail browsing, legacy bookmark aliases, and related-run handoffs.
|
||||
- `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/contextual-actions-patterns/README.md` - shared placement contract for stray actions, pages, drawers, and tabs.
|
||||
|
||||
@@ -347,6 +347,18 @@ export const routes: Routes = [
|
||||
{ path: '**', redirectTo: '/evidence/overview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'evidence-packs',
|
||||
children: [
|
||||
{ path: '', redirectTo: preserveAppRedirect('/evidence/capsules'), pathMatch: 'full' },
|
||||
{
|
||||
path: ':capsuleId',
|
||||
redirectTo: preserveAppRedirect('/evidence/capsules/:capsuleId'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{ path: '**', redirectTo: preserveAppRedirect('/evidence/capsules') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'security-risk',
|
||||
children: [
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
AttestationContent,
|
||||
} from '../../core/api/ai-runs.models';
|
||||
import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state';
|
||||
|
||||
@Component({
|
||||
selector: 'stellaops-ai-run-viewer',
|
||||
@@ -912,7 +913,14 @@ export class AiRunViewerComponent implements OnInit, OnChanges {
|
||||
|
||||
onNavigateToEvidencePack(packId: string): void {
|
||||
this.navigateToEvidencePack.emit(packId);
|
||||
this.router.navigate(['/evidence-packs', packId]);
|
||||
const currentRunId = this.runId ?? this.run()?.runId ?? '';
|
||||
void this.router.navigate(['/evidence/capsules', packId], {
|
||||
queryParams: currentRunId
|
||||
? {
|
||||
returnTo: buildContextReturnTo(this.router, ['/ops/operations/ai-runs', currentRunId]),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
formatEventType(type: string): string {
|
||||
|
||||
@@ -31,7 +31,10 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
|
||||
<div class="evidence-pack-list">
|
||||
<!-- Header with filters -->
|
||||
<header class="list-header">
|
||||
<h2 class="list-title">Evidence Packs</h2>
|
||||
<div>
|
||||
<h2 class="list-title">Decision Capsules</h2>
|
||||
<p class="list-subtitle">Browse signed evidence packs that explain release, policy, and operator decisions.</p>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<input
|
||||
type="text"
|
||||
@@ -51,11 +54,11 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading evidence packs...</p>
|
||||
<p>Loading decision capsules...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<app-error-state
|
||||
title="Unable to load evidence packs"
|
||||
title="Unable to load decision capsules"
|
||||
message="The evidence service is currently unavailable. Please try again."
|
||||
[rawError]="error()"
|
||||
(retry)="loadPacks()"
|
||||
@@ -67,7 +70,7 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
<p>No evidence packs found</p>
|
||||
<p>No decision capsules found</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pack-grid">
|
||||
@@ -157,6 +160,12 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list-subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -393,7 +402,7 @@ export class EvidencePackListComponent implements OnInit {
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to load evidence packs');
|
||||
this.error.set(err.message || 'Failed to load decision capsules');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
@@ -411,6 +420,8 @@ export class EvidencePackListComponent implements OnInit {
|
||||
|
||||
onSelect(pack: EvidencePackSummary): void {
|
||||
this.packSelected.emit(pack);
|
||||
this.router.navigate(['/evidence-packs', pack.packId]);
|
||||
void this.router.navigate(['/evidence/capsules', pack.packId], {
|
||||
queryParams: { returnTo: this.router.url },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
DestroyRef,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
OnInit,
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
} from '../../core/api/evidence-pack.models';
|
||||
import { EVIDENCE_PACK_API } from '../../core/api/evidence-pack.client';
|
||||
import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component';
|
||||
import { buildContextReturnTo, readContextRouteParam } from '../../shared/ui/context-route-state/context-route-state';
|
||||
|
||||
@Component({
|
||||
selector: 'stellaops-evidence-pack-viewer',
|
||||
@@ -52,7 +55,10 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
|
||||
<!-- Header -->
|
||||
<header class="pack-header">
|
||||
<div class="header-left">
|
||||
<h2 class="pack-title">Evidence Pack</h2>
|
||||
<button type="button" class="back-link" (click)="goBack()">
|
||||
<- {{ backLabel() }}
|
||||
</button>
|
||||
<h2 class="pack-title">Decision Capsule</h2>
|
||||
<span class="pack-id">{{ pack()!.packId }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@@ -379,9 +385,24 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pack-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
@@ -750,6 +771,7 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges {
|
||||
private readonly api = inject(EVIDENCE_PACK_API);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
@Input() packId?: string;
|
||||
@Input() initialPack?: EvidencePack;
|
||||
@@ -765,18 +787,23 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges {
|
||||
readonly signing = signal(false);
|
||||
readonly verifying = signal(false);
|
||||
readonly showExportMenu = signal(false);
|
||||
readonly returnTo = signal<string | null>(null);
|
||||
|
||||
readonly isSigned = computed(() => this.signedPack() !== null);
|
||||
readonly backLabel = computed(() => (this.returnTo() ? 'Back to Previous Context' : 'Back to Decision Capsules'));
|
||||
|
||||
ngOnInit(): void {
|
||||
// Read packId from route params if not provided via Input
|
||||
this.route.paramMap.subscribe((params) => {
|
||||
const routePackId = params.get('packId');
|
||||
if (routePackId && !this.packId) {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
const routePackId = params.get('capsuleId') ?? params.get('packId');
|
||||
if (routePackId && routePackId !== this.packId) {
|
||||
this.packId = routePackId;
|
||||
this.loadPack();
|
||||
}
|
||||
});
|
||||
|
||||
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
this.returnTo.set(readContextRouteParam(params, 'returnTo'));
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
@@ -799,7 +826,7 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges {
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to load evidence pack');
|
||||
this.error.set(err.message || 'Failed to load decision capsule');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
@@ -870,7 +897,21 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges {
|
||||
|
||||
onNavigateToRun(runId: string): void {
|
||||
this.navigateToRun.emit(runId);
|
||||
this.router.navigate(['/ai-runs', runId]);
|
||||
void this.router.navigate(this.resolveRunCommands(runId), {
|
||||
queryParams: {
|
||||
returnTo: this.capsuleReturnTo(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
const returnTo = this.returnTo();
|
||||
if (returnTo) {
|
||||
void this.router.navigateByUrl(returnTo);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.router.navigate(['/evidence/capsules']);
|
||||
}
|
||||
|
||||
private downloadBlob(blob: Blob, filename: string): void {
|
||||
@@ -897,4 +938,39 @@ export class EvidencePackViewerComponent implements OnInit, OnChanges {
|
||||
return 'json';
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRunCommands(runId: string): readonly string[] {
|
||||
if (this.isAiRunContext()) {
|
||||
return ['/ops/operations/ai-runs', runId];
|
||||
}
|
||||
|
||||
return ['/releases/runs', runId, 'evidence'];
|
||||
}
|
||||
|
||||
private capsuleReturnTo(): string {
|
||||
const packId = this.pack()?.packId ?? this.packId;
|
||||
if (!packId) {
|
||||
return this.router.url;
|
||||
}
|
||||
|
||||
return buildContextReturnTo(
|
||||
this.router,
|
||||
['/evidence/capsules', packId],
|
||||
this.returnTo() ? { returnTo: this.returnTo() } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private isAiRunContext(): boolean {
|
||||
const context = this.pack()?.context;
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((context.conversationId ?? '').trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const generator = (context.generatedBy ?? '').toLowerCase();
|
||||
return generator.includes('advisoryai') || generator.includes('advisory ai') || generator.includes('ai run');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
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 { AI_RUNS_API, type AiRunsApi } from '../../app/core/api/ai-runs.client';
|
||||
import { EVIDENCE_PACK_API, type EvidencePackApi } from '../../app/core/api/evidence-pack.client';
|
||||
import type { AiRun } from '../../app/core/api/ai-runs.models';
|
||||
import type { EvidencePack, EvidencePackSummary } from '../../app/core/api/evidence-pack.models';
|
||||
import { AiRunViewerComponent } from '../../app/features/ai-runs/ai-run-viewer.component';
|
||||
import { EvidencePackListComponent } from '../../app/features/evidence-pack/evidence-pack-list.component';
|
||||
import { EvidencePackViewerComponent } from '../../app/features/evidence-pack/evidence-pack-viewer.component';
|
||||
import { EVIDENCE_ROUTES } from '../../app/routes/evidence.routes';
|
||||
import { buildContextReturnTo } from '../../app/shared/ui/context-route-state/context-route-state';
|
||||
|
||||
function resolveRedirect(
|
||||
route: Route | undefined,
|
||||
params: Record<string, string> = {},
|
||||
queryParams: Record<string, string> = { scope: 'release' },
|
||||
): 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,
|
||||
fragment: 'capsule',
|
||||
} as never) as unknown;
|
||||
|
||||
return typeof target === 'string' ? target : router.serializeUrl(target as never);
|
||||
});
|
||||
}
|
||||
|
||||
const releaseCapsule: EvidencePack = {
|
||||
packId: 'cap-rel-001',
|
||||
version: '1.0.0',
|
||||
createdAt: '2026-03-08T10:00:00Z',
|
||||
tenantId: 'tenant-default',
|
||||
subject: {
|
||||
type: 'Finding',
|
||||
findingId: 'finding-001',
|
||||
cveId: 'CVE-2026-1111',
|
||||
component: 'pkg:oci/api-gateway@2.1.0',
|
||||
},
|
||||
claims: [],
|
||||
evidence: [],
|
||||
context: {
|
||||
runId: 'run-rel-001',
|
||||
generatedBy: 'Release Orchestrator',
|
||||
},
|
||||
};
|
||||
|
||||
const aiCapsule: EvidencePack = {
|
||||
packId: 'cap-ai-001',
|
||||
version: '1.0.0',
|
||||
createdAt: '2026-03-08T10:05:00Z',
|
||||
tenantId: 'tenant-default',
|
||||
subject: {
|
||||
type: 'Cve',
|
||||
cveId: 'CVE-2026-2222',
|
||||
component: 'pkg:npm/example@1.2.3',
|
||||
},
|
||||
claims: [],
|
||||
evidence: [],
|
||||
context: {
|
||||
runId: 'run-ai-001',
|
||||
conversationId: 'conv-001',
|
||||
generatedBy: 'AdvisoryAI v2.1',
|
||||
},
|
||||
};
|
||||
|
||||
describe('evidence capsules cutover contract', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([])],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps canonical capsules mounted under the Evidence shell', () => {
|
||||
const paths = EVIDENCE_ROUTES.map((route) => route.path);
|
||||
|
||||
expect(paths).toContain('capsules');
|
||||
expect(paths).toContain('capsules/:capsuleId');
|
||||
});
|
||||
|
||||
it('retargets legacy evidence-pack bookmarks to canonical capsule routes', () => {
|
||||
const evidencePacks = routes.find((route) => route.path === 'evidence-packs');
|
||||
const children = evidencePacks?.children ?? [];
|
||||
|
||||
expect(resolveRedirect(children.find((route) => route.path === ''))).toBe(
|
||||
'/evidence/capsules?scope=release#capsule',
|
||||
);
|
||||
expect(resolveRedirect(children.find((route) => route.path === ':capsuleId'), { capsuleId: 'cap-rel-001' })).toBe(
|
||||
'/evidence/capsules/cap-rel-001?scope=release#capsule',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EvidencePackListComponent canonical navigation', () => {
|
||||
it('opens capsule detail on the canonical evidence route with a return-to link', async () => {
|
||||
const evidenceApi = jasmine.createSpyObj('EvidencePackApi', [
|
||||
'list',
|
||||
'listByRun',
|
||||
'get',
|
||||
'create',
|
||||
'sign',
|
||||
'verify',
|
||||
'export',
|
||||
]) as jasmine.SpyObj<EvidencePackApi>;
|
||||
evidenceApi.list.and.returnValue(of({ count: 0, packs: [] }));
|
||||
evidenceApi.listByRun.and.returnValue(of({ count: 0, packs: [] }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidencePackListComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: EVIDENCE_PACK_API, useValue: evidenceApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(EvidencePackListComponent);
|
||||
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: () => '/evidence/capsules?scope=release&runId=run-rel-001',
|
||||
});
|
||||
|
||||
const pack: EvidencePackSummary = {
|
||||
packId: 'cap-rel-001',
|
||||
tenantId: 'tenant-default',
|
||||
createdAt: '2026-03-08T10:00:00Z',
|
||||
subjectType: 'Finding',
|
||||
cveId: 'CVE-2026-1111',
|
||||
claimCount: 4,
|
||||
evidenceCount: 7,
|
||||
};
|
||||
|
||||
component.onSelect(pack);
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/evidence/capsules', 'cap-rel-001'], {
|
||||
queryParams: { returnTo: '/evidence/capsules?scope=release&runId=run-rel-001' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('EvidencePackViewerComponent capsule routing and handoffs', () => {
|
||||
it('hydrates from the canonical capsule param and opens the release run evidence tab with a return-to link', async () => {
|
||||
const evidenceApi = jasmine.createSpyObj('EvidencePackApi', [
|
||||
'get',
|
||||
'list',
|
||||
'listByRun',
|
||||
'create',
|
||||
'sign',
|
||||
'verify',
|
||||
'export',
|
||||
]) as jasmine.SpyObj<EvidencePackApi>;
|
||||
evidenceApi.get.and.returnValue(of(releaseCapsule));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidencePackViewerComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ capsuleId: 'cap-rel-001' })),
|
||||
queryParamMap: of(convertToParamMap({ returnTo: '/evidence/capsules?scope=release' })),
|
||||
},
|
||||
},
|
||||
{ provide: EVIDENCE_PACK_API, useValue: evidenceApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(EvidencePackViewerComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.packId).toBe('cap-rel-001');
|
||||
expect(component.returnTo()).toBe('/evidence/capsules?scope=release');
|
||||
expect(component.backLabel()).toBe('Back to Previous Context');
|
||||
|
||||
component.onNavigateToRun('run-rel-001');
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/releases/runs', 'run-rel-001', 'evidence'], {
|
||||
queryParams: {
|
||||
returnTo: buildContextReturnTo(
|
||||
router,
|
||||
['/evidence/capsules', 'cap-rel-001'],
|
||||
{ returnTo: '/evidence/capsules?scope=release' },
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns to the originating context when a return-to query is present', async () => {
|
||||
const evidenceApi = jasmine.createSpyObj('EvidencePackApi', [
|
||||
'get',
|
||||
'list',
|
||||
'listByRun',
|
||||
'create',
|
||||
'sign',
|
||||
'verify',
|
||||
'export',
|
||||
]) as jasmine.SpyObj<EvidencePackApi>;
|
||||
evidenceApi.get.and.returnValue(of(releaseCapsule));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidencePackViewerComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ capsuleId: 'cap-rel-001' })),
|
||||
queryParamMap: of(convertToParamMap({ returnTo: '/ops/operations/ai-runs/run-ai-001' })),
|
||||
},
|
||||
},
|
||||
{ provide: EVIDENCE_PACK_API, useValue: evidenceApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(EvidencePackViewerComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
const navigateByUrlSpy = spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true));
|
||||
|
||||
fixture.detectChanges();
|
||||
component.goBack();
|
||||
|
||||
expect(navigateByUrlSpy).toHaveBeenCalledWith('/ops/operations/ai-runs/run-ai-001');
|
||||
});
|
||||
|
||||
it('sends AI-generated capsules back to the live AI Runs shell', async () => {
|
||||
const evidenceApi = jasmine.createSpyObj('EvidencePackApi', [
|
||||
'get',
|
||||
'list',
|
||||
'listByRun',
|
||||
'create',
|
||||
'sign',
|
||||
'verify',
|
||||
'export',
|
||||
]) as jasmine.SpyObj<EvidencePackApi>;
|
||||
evidenceApi.get.and.returnValue(of(aiCapsule));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidencePackViewerComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ capsuleId: 'cap-ai-001' })),
|
||||
queryParamMap: of(convertToParamMap({})),
|
||||
},
|
||||
},
|
||||
{ provide: EVIDENCE_PACK_API, useValue: evidenceApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(EvidencePackViewerComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
fixture.detectChanges();
|
||||
component.onNavigateToRun('run-ai-001');
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/ops/operations/ai-runs', 'run-ai-001'], {
|
||||
queryParams: {
|
||||
returnTo: buildContextReturnTo(router, ['/evidence/capsules', 'cap-ai-001']),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiRunViewerComponent capsule handoff', () => {
|
||||
it('deep-links evidence-pack events into canonical capsule detail routes', async () => {
|
||||
const aiRunsApi = jasmine.createSpyObj('AiRunsApi', [
|
||||
'get',
|
||||
'list',
|
||||
'getTimeline',
|
||||
'getArtifacts',
|
||||
'create',
|
||||
'addTurn',
|
||||
'proposeAction',
|
||||
'submitApproval',
|
||||
'complete',
|
||||
'createAttestation',
|
||||
'cancel',
|
||||
]) as jasmine.SpyObj<AiRunsApi>;
|
||||
const aiRun: AiRun = {
|
||||
runId: 'run-ai-001',
|
||||
tenantId: 'tenant-default',
|
||||
userId: 'operator@example.com',
|
||||
status: 'complete',
|
||||
createdAt: '2026-03-08T10:00:00Z',
|
||||
updatedAt: '2026-03-08T10:05:00Z',
|
||||
completedAt: '2026-03-08T10:05:00Z',
|
||||
timeline: [],
|
||||
artifacts: [],
|
||||
conversationId: 'conv-001',
|
||||
};
|
||||
aiRunsApi.get.and.returnValue(of(aiRun));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AiRunViewerComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ runId: 'run-ai-001' })),
|
||||
},
|
||||
},
|
||||
{ provide: AI_RUNS_API, useValue: aiRunsApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(AiRunViewerComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
fixture.detectChanges();
|
||||
component.onNavigateToEvidencePack('cap-ai-001');
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/evidence/capsules', 'cap-ai-001'], {
|
||||
queryParams: {
|
||||
returnTo: buildContextReturnTo(router, ['/ops/operations/ai-runs', 'run-ai-001']),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
|
||||
|
||||
const operatorSession: StubAuthSession = {
|
||||
subjectId: 'evidence-capsules-e2e-user',
|
||||
tenant: 'tenant-default',
|
||||
scopes: [
|
||||
'admin',
|
||||
'ui.read',
|
||||
'ui.admin',
|
||||
'release:read',
|
||||
'policy:read',
|
||||
'policy:audit',
|
||||
'signer:read',
|
||||
],
|
||||
};
|
||||
|
||||
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 aiRun = {
|
||||
runId: 'run-ai-001',
|
||||
tenantId: operatorSession.tenant,
|
||||
userId: 'operator@example.com',
|
||||
conversationId: 'conv-001',
|
||||
status: 'complete',
|
||||
createdAt: '2026-03-08T10:00:00Z',
|
||||
updatedAt: '2026-03-08T10:05:00Z',
|
||||
completedAt: '2026-03-08T10:05:00Z',
|
||||
timeline: [
|
||||
{
|
||||
eventId: 'event-001',
|
||||
type: 'evidence_pack_created',
|
||||
timestamp: '2026-03-08T10:02:00Z',
|
||||
content: {
|
||||
kind: 'evidence_pack',
|
||||
packId: 'cap-ai-001',
|
||||
claimCount: 3,
|
||||
evidenceCount: 5,
|
||||
contentDigest: 'sha256:cap-ai-001',
|
||||
},
|
||||
},
|
||||
],
|
||||
artifacts: [],
|
||||
};
|
||||
|
||||
const aiCapsule = {
|
||||
packId: 'cap-ai-001',
|
||||
version: '1.0.0',
|
||||
createdAt: '2026-03-08T10:02:30Z',
|
||||
tenantId: operatorSession.tenant,
|
||||
subject: {
|
||||
type: 'Cve',
|
||||
cveId: 'CVE-2026-2222',
|
||||
component: 'pkg:npm/example@1.2.3',
|
||||
},
|
||||
claims: [
|
||||
{
|
||||
claimId: 'claim-ai-001',
|
||||
text: 'The vulnerability is not exploitable in this conversation scope.',
|
||||
type: 'VulnerabilityStatus',
|
||||
status: 'not_affected',
|
||||
confidence: 0.91,
|
||||
evidenceIds: ['ev-ai-001'],
|
||||
source: 'ai',
|
||||
},
|
||||
],
|
||||
evidence: [
|
||||
{
|
||||
evidenceId: 'ev-ai-001',
|
||||
type: 'Attestation',
|
||||
uri: 'stella://attestor/cap-ai-001',
|
||||
digest: 'sha256:ev-ai-001',
|
||||
collectedAt: '2026-03-08T10:02:10Z',
|
||||
snapshot: {
|
||||
type: 'attestation',
|
||||
data: { signed: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
context: {
|
||||
runId: 'run-ai-001',
|
||||
conversationId: 'conv-001',
|
||||
generatedBy: 'AdvisoryAI v2.1',
|
||||
},
|
||||
};
|
||||
|
||||
const releaseCapsule = {
|
||||
packId: 'cap-rel-001',
|
||||
version: '1.0.0',
|
||||
createdAt: '2026-03-08T10:03:00Z',
|
||||
tenantId: operatorSession.tenant,
|
||||
subject: {
|
||||
type: 'Finding',
|
||||
findingId: 'finding-rel-001',
|
||||
cveId: 'CVE-2026-1111',
|
||||
component: 'pkg:oci/payments@4.2.0',
|
||||
},
|
||||
claims: [
|
||||
{
|
||||
claimId: 'claim-rel-001',
|
||||
text: 'Release evidence is fully signed and replay matched.',
|
||||
type: 'Compliance',
|
||||
status: 'verified',
|
||||
confidence: 0.98,
|
||||
evidenceIds: ['ev-rel-001'],
|
||||
source: 'system',
|
||||
},
|
||||
],
|
||||
evidence: [
|
||||
{
|
||||
evidenceId: 'ev-rel-001',
|
||||
type: 'Policy',
|
||||
uri: 'stella://policy/run-rel-001',
|
||||
digest: 'sha256:ev-rel-001',
|
||||
collectedAt: '2026-03-08T10:02:45Z',
|
||||
snapshot: {
|
||||
type: 'policy',
|
||||
data: { verdict: 'pass' },
|
||||
},
|
||||
},
|
||||
],
|
||||
context: {
|
||||
runId: 'run-rel-001',
|
||||
generatedBy: 'Release Orchestrator',
|
||||
},
|
||||
};
|
||||
|
||||
const releaseRunDetail = {
|
||||
runId: 'run-rel-001',
|
||||
releaseId: 'rel-001',
|
||||
releaseName: 'Payments API',
|
||||
releaseSlug: 'payments-api',
|
||||
releaseType: 'standard',
|
||||
releaseVersionId: 'ver-001',
|
||||
releaseVersionNumber: 42,
|
||||
releaseVersionDigest: 'sha256:release-001',
|
||||
lane: 'standard',
|
||||
status: 'running',
|
||||
outcome: 'in_progress',
|
||||
targetEnvironment: 'prod',
|
||||
targetRegion: 'eu-west',
|
||||
scopeSummary: 'stage -> prod',
|
||||
requestedAt: '2026-03-08T09:58:00Z',
|
||||
updatedAt: '2026-03-08T10:04:00Z',
|
||||
needsApproval: false,
|
||||
blockedByDataIntegrity: false,
|
||||
correlationKey: 'corr-rel-001',
|
||||
statusRow: {
|
||||
runStatus: 'running',
|
||||
gateStatus: 'passed',
|
||||
approvalStatus: 'not-required',
|
||||
dataTrustStatus: 'healthy',
|
||||
},
|
||||
};
|
||||
|
||||
const releaseRunEvidence = {
|
||||
runId: 'run-rel-001',
|
||||
replayDeterminismVerdict: 'match',
|
||||
replayMismatch: false,
|
||||
signatureStatus: 'verified',
|
||||
};
|
||||
|
||||
const releaseRunTimeline = {
|
||||
runId: 'run-rel-001',
|
||||
events: [
|
||||
{
|
||||
eventId: 'timeline-001',
|
||||
eventClass: 'scan_completed',
|
||||
phase: 'ingest',
|
||||
status: 'completed',
|
||||
occurredAt: '2026-03-08T09:59:00Z',
|
||||
message: 'Ingest and scan completed for Payments API release.',
|
||||
},
|
||||
{
|
||||
eventId: 'timeline-002',
|
||||
eventClass: 'gate_passed',
|
||||
phase: 'gate',
|
||||
status: 'passed',
|
||||
occurredAt: '2026-03-08T10:00:30Z',
|
||||
message: 'Policy gate passed without blockers.',
|
||||
},
|
||||
{
|
||||
eventId: 'timeline-003',
|
||||
eventClass: 'evidence_verified',
|
||||
phase: 'evidence',
|
||||
status: 'completed',
|
||||
occurredAt: '2026-03-08T10:02:45Z',
|
||||
message: 'Evidence bundle signatures verified.',
|
||||
},
|
||||
{
|
||||
eventId: 'timeline-004',
|
||||
eventClass: 'deployment_running',
|
||||
phase: 'deployment',
|
||||
status: 'running',
|
||||
occurredAt: '2026-03-08T10:04:00Z',
|
||||
message: 'Production deployment is in progress.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const releaseRunGateDecision = {
|
||||
runId: 'run-rel-001',
|
||||
verdict: 'passed',
|
||||
blockers: [],
|
||||
riskBudgetDelta: 0,
|
||||
};
|
||||
|
||||
const releaseRunApprovals = {
|
||||
runId: 'run-rel-001',
|
||||
checkpoints: [],
|
||||
};
|
||||
|
||||
const releaseRunDeployments = {
|
||||
runId: 'run-rel-001',
|
||||
targets: [
|
||||
{
|
||||
targetId: 'target-001',
|
||||
targetName: 'payments-prod-eu-west',
|
||||
environment: 'prod',
|
||||
region: 'eu-west',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const releaseRunSecurityInputs = {
|
||||
runId: 'run-rel-001',
|
||||
reachabilityCoveragePercent: 97,
|
||||
feedFreshnessStatus: 'fresh',
|
||||
vexStatementsApplied: 2,
|
||||
exceptionsApplied: 0,
|
||||
};
|
||||
|
||||
const releaseRunReplay = {
|
||||
runId: 'run-rel-001',
|
||||
verdict: 'match',
|
||||
};
|
||||
|
||||
const releaseRunAudit = {
|
||||
runId: 'run-rel-001',
|
||||
entries: [
|
||||
{
|
||||
auditId: 'audit-001',
|
||||
action: 'evidence_verified',
|
||||
actorId: 'release-orchestrator',
|
||||
occurredAt: '2026-03-08T10:02:45Z',
|
||||
correlationKey: 'corr-rel-001',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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('**/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: 'evidence-capsules-e2e',
|
||||
displayName: 'Evidence Capsules E2E',
|
||||
tenant: operatorSession.tenant,
|
||||
roles: ['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('**/console/tenants**', (route) =>
|
||||
fulfillJson(route, {
|
||||
tenants: [
|
||||
{
|
||||
id: operatorSession.tenant,
|
||||
displayName: 'Default Tenant',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['admin'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
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: 'prod',
|
||||
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: ['prod'],
|
||||
timeWindow: '24h',
|
||||
stage: 'all',
|
||||
updatedAt: '2026-03-08T09:30:00Z',
|
||||
updatedBy: operatorSession.subjectId,
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/v1/runs/run-ai-001**', (route) => fulfillJson(route, aiRun));
|
||||
await page.route('**/v1/evidence-packs/cap-ai-001**', (route) => fulfillJson(route, aiCapsule));
|
||||
await page.route('**/v1/evidence-packs/cap-rel-001**', (route) => fulfillJson(route, releaseCapsule));
|
||||
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const requestUrl = route.request().url();
|
||||
if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/timeline')) {
|
||||
return fulfillJson(route, releaseRunTimeline);
|
||||
}
|
||||
if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/gate-decision')) {
|
||||
return fulfillJson(route, releaseRunGateDecision);
|
||||
}
|
||||
if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/approvals')) {
|
||||
return fulfillJson(route, releaseRunApprovals);
|
||||
}
|
||||
if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/deployments')) {
|
||||
return fulfillJson(route, releaseRunDeployments);
|
||||
}
|
||||
if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/security-inputs')) {
|
||||
return fulfillJson(route, releaseRunSecurityInputs);
|
||||
}
|
||||
if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/evidence')) {
|
||||
return fulfillJson(route, releaseRunEvidence);
|
||||
}
|
||||
if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/replay')) {
|
||||
return fulfillJson(route, releaseRunReplay);
|
||||
}
|
||||
if (requestUrl.includes('/api/v2/releases/runs/run-rel-001/audit')) {
|
||||
return fulfillJson(route, releaseRunAudit);
|
||||
}
|
||||
if (requestUrl.includes('/api/v2/releases/runs/run-rel-001')) {
|
||||
return fulfillJson(route, releaseRunDetail);
|
||||
}
|
||||
if (requestUrl.includes('/api/v1/workflows/run-rel-001')) {
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'not found' }),
|
||||
});
|
||||
}
|
||||
|
||||
return fulfillJson(route, {});
|
||||
});
|
||||
await page.route('**/gateway/**', (route) => {
|
||||
const requestUrl = route.request().url();
|
||||
if (requestUrl.includes('/v1/runs/run-ai-001')) {
|
||||
return fulfillJson(route, aiRun);
|
||||
}
|
||||
if (requestUrl.includes('/v1/evidence-packs/cap-ai-001')) {
|
||||
return fulfillJson(route, aiCapsule);
|
||||
}
|
||||
if (requestUrl.includes('/v1/evidence-packs/cap-rel-001')) {
|
||||
return fulfillJson(route, releaseCapsule);
|
||||
}
|
||||
|
||||
return fulfillJson(route, {});
|
||||
});
|
||||
await page.route('**/policy/**', (route) => fulfillJson(route, {}));
|
||||
await page.route('**/scanner/**', (route) => fulfillJson(route, {}));
|
||||
await page.route('**/concelier/**', (route) => fulfillJson(route, {}));
|
||||
await page.route('**/attestor/**', (route) => fulfillJson(route, {}));
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupHarness(page);
|
||||
});
|
||||
|
||||
test('AI runs deep-link into canonical decision capsules and return to the live AI run context', async ({ page }) => {
|
||||
await page.goto('/ops/operations/ai-runs/run-ai-001', { waitUntil: 'networkidle' });
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'AI Run' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'cap-ai-001' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/evidence\/capsules\/cap-ai-001\?returnTo=/);
|
||||
await expect(page.getByRole('heading', { name: 'Decision Capsule' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Back to Previous Context/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/ops\/operations\/ai-runs\/run-ai-001$/);
|
||||
await expect(page.getByRole('heading', { name: 'AI Run' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('legacy evidence-pack bookmarks land on canonical capsules and related runs open the live release workspace', async ({ page }) => {
|
||||
await page.goto('/evidence-packs/cap-rel-001?scope=release', { waitUntil: 'networkidle' });
|
||||
|
||||
await expect(page).toHaveURL(/\/evidence\/capsules\/cap-rel-001\?scope=release$/);
|
||||
await expect(page.getByRole('heading', { name: 'Decision Capsule' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'run-rel-001' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/releases\/runs\/run-rel-001\/evidence\?returnTo=/);
|
||||
await expect(page.getByRole('heading', { name: 'Payments API' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Determinism', exact: true })).toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user