Repair live releases deployment detail flows
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const outputDirectory = path.join(webRoot, 'output', 'playwright');
|
||||
|
||||
const BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
||||
const LIST_URL = `${BASE_URL}/releases/deployments?tenant=demo-prod®ions=us-east&environments=stage`;
|
||||
const DETAIL_URL = `${BASE_URL}/releases/deployments/DEP-2026-050?tenant=demo-prod®ions=us-east&environments=stage`;
|
||||
const STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
|
||||
const REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
|
||||
const RESULT_PATH = path.join(outputDirectory, 'live-releases-deployments-check.json');
|
||||
|
||||
async function seedAuthenticatedPage(browser, authReport) {
|
||||
const context = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
acceptDownloads: true,
|
||||
storageState: STATE_PATH,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${BASE_URL}/welcome`, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await page.evaluate((storage) => {
|
||||
sessionStorage.clear();
|
||||
for (const [key, value] of storage.sessionStorageEntries ?? []) {
|
||||
if (typeof key === 'string' && typeof value === 'string') {
|
||||
sessionStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
}, authReport.storage);
|
||||
|
||||
await page.goto(LIST_URL, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(outputDirectory, { recursive: true });
|
||||
|
||||
await authenticateFrontdoor({
|
||||
baseUrl: BASE_URL,
|
||||
statePath: STATE_PATH,
|
||||
reportPath: REPORT_PATH,
|
||||
headless: true,
|
||||
});
|
||||
|
||||
const authReport = JSON.parse(readFileSync(REPORT_PATH, 'utf8'));
|
||||
const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] });
|
||||
|
||||
try {
|
||||
const { context, page } = await seedAuthenticatedPage(browser, authReport);
|
||||
const result = {
|
||||
checkedAtUtc: new Date().toISOString(),
|
||||
listUrl: page.url(),
|
||||
listHeading: await page.locator('h1').first().textContent(),
|
||||
releaseVersionAnchors: await page.locator('tbody tr td:nth-child(2) a').count(),
|
||||
firstDeploymentHref: '',
|
||||
detailUrl: '',
|
||||
detailHeading: '',
|
||||
reloadedDetailUrl: '',
|
||||
reloadedDetailHeading: '',
|
||||
reloadedDetailButtons: [],
|
||||
replayUrl: '',
|
||||
evidenceUrl: '',
|
||||
evidenceHeading: null,
|
||||
evidenceWorkspaceHref: '',
|
||||
evidenceWorkspaceUrl: '',
|
||||
proofChainsHref: '',
|
||||
proofChainsUrl: '',
|
||||
copyHashStatus: '',
|
||||
artifactViewUrl: '',
|
||||
artifactViewStatus: '',
|
||||
artifactDownloadSuggestedFilename: '',
|
||||
logsDownloadSuggestedFilename: '',
|
||||
detailActionStatus: '',
|
||||
};
|
||||
const headerActions = page.locator('.deployment-detail .header-actions button');
|
||||
|
||||
result.firstDeploymentHref = (await page.locator('tbody a.deployment-link').first().getAttribute('href')) ?? '';
|
||||
|
||||
await page.goto(DETAIL_URL, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
result.detailUrl = page.url();
|
||||
result.detailHeading = (await page.locator('h1').first().textContent()) ?? '';
|
||||
process.stdout.write(`[live-releases-deployments-check] detail ${result.detailUrl} :: ${result.detailHeading}\n`);
|
||||
|
||||
await headerActions.nth(1).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
result.replayUrl = page.url();
|
||||
|
||||
await page.goto(result.detailUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
result.reloadedDetailUrl = page.url();
|
||||
result.reloadedDetailHeading = (await page.locator('h1').first().textContent().catch(() => '')) ?? '';
|
||||
result.reloadedDetailButtons = await page.locator('button').allTextContents();
|
||||
process.stdout.write(
|
||||
`[live-releases-deployments-check] reloaded ${result.reloadedDetailUrl} :: ${result.reloadedDetailHeading} :: ${result.reloadedDetailButtons.join(' | ')}\n`,
|
||||
);
|
||||
await headerActions.nth(0).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
result.evidenceUrl = page.url();
|
||||
result.evidenceHeading = await page.locator('.tab-content h3').first().textContent().catch(() => null);
|
||||
result.evidenceWorkspaceHref = (await page.locator('.evidence-info a').getAttribute('href')) ?? '';
|
||||
await page.goto(new URL(result.evidenceWorkspaceHref, BASE_URL).toString(), { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
result.evidenceWorkspaceUrl = page.url();
|
||||
|
||||
await page.goto(result.detailUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await headerActions.nth(0).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
result.proofChainsHref = (await page.locator('.rekor-link').getAttribute('href')) ?? '';
|
||||
await page.goto(new URL(result.proofChainsHref, BASE_URL).toString(), { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
result.proofChainsUrl = page.url();
|
||||
|
||||
await page.goto(result.detailUrl, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.getByRole('button', { name: 'Artifacts' }).click();
|
||||
await page.getByTitle('Copy full hash').first().click();
|
||||
await page.waitForTimeout(500);
|
||||
result.copyHashStatus =
|
||||
(await page.locator('.deployment-detail .action-status').first().textContent().catch(() => ''))?.trim() ?? '';
|
||||
|
||||
const popupPromise = page.waitForEvent('popup', { timeout: 5_000 }).catch(() => null);
|
||||
await page.getByRole('button', { name: 'View' }).first().click();
|
||||
const popup = await popupPromise;
|
||||
result.artifactViewUrl = popup?.url() ?? '';
|
||||
result.artifactViewStatus =
|
||||
(await page.locator('.deployment-detail .action-status').first().textContent().catch(() => ''))?.trim() ?? '';
|
||||
await popup?.close();
|
||||
|
||||
const artifactDownloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Download' }).first().click();
|
||||
const artifactDownload = await artifactDownloadPromise;
|
||||
result.artifactDownloadSuggestedFilename = artifactDownload.suggestedFilename();
|
||||
|
||||
await page.getByRole('button', { name: 'Logs' }).click();
|
||||
const logsDownloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
const logsDownload = await logsDownloadPromise;
|
||||
result.logsDownloadSuggestedFilename = logsDownload.suggestedFilename();
|
||||
|
||||
result.detailActionStatus =
|
||||
(await page.locator('.deployment-detail .action-status').first().textContent().catch(() => ''))?.trim() ?? '';
|
||||
|
||||
writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
await context.close();
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[live-releases-deployments-check] ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit, ElementRef, ViewChild, AfterViewChecked } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
interface WorkflowStep {
|
||||
@@ -36,7 +36,7 @@ interface DeploymentArtifact {
|
||||
template: `
|
||||
<div class="deployment-detail">
|
||||
<header class="page-header">
|
||||
<a routerLink="/deployments" class="back-link">← Back to Deployments</a>
|
||||
<a routerLink="/releases/deployments" class="back-link">← Back to Deployments</a>
|
||||
<div class="header-main">
|
||||
<div class="header-title-row">
|
||||
<h1 class="page-title">{{ deployment().id }}</h1>
|
||||
@@ -55,15 +55,13 @@ interface DeploymentArtifact {
|
||||
<button type="button" class="btn btn--secondary" (click)="openEvidence()">
|
||||
Open Evidence
|
||||
</button>
|
||||
@if (deployment().status === 'success') {
|
||||
<button type="button" class="btn btn--secondary" (click)="rollback()">
|
||||
Rollback
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn--secondary" (click)="replayVerify()">
|
||||
Replay Verify
|
||||
</button>
|
||||
</div>
|
||||
@if (actionMessage()) {
|
||||
<p class="action-status" role="status">{{ actionMessage() }}</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- Tabs -->
|
||||
@@ -291,7 +289,12 @@ interface DeploymentArtifact {
|
||||
<h3>Deployment Evidence</h3>
|
||||
<p class="evidence-info">
|
||||
Evidence for this deployment is sealed and signed.
|
||||
<a [routerLink]="['/evidence', deployment().evidenceId]">View full evidence packet</a>
|
||||
<a
|
||||
[routerLink]="['/evidence/capsules']"
|
||||
[queryParams]="{ evidenceId: deployment().evidenceId, returnTo: buildReturnToUrl() }"
|
||||
>
|
||||
Open evidence workspace
|
||||
</a>
|
||||
</p>
|
||||
<div class="evidence-summary">
|
||||
<div class="evidence-item">
|
||||
@@ -307,8 +310,14 @@ interface DeploymentArtifact {
|
||||
<span class="evidence-badge evidence-badge--success">Yes</span>
|
||||
</div>
|
||||
<div class="evidence-item">
|
||||
<span class="evidence-label">Rekor Entry</span>
|
||||
<a href="#" class="rekor-link">View in Rekor</a>
|
||||
<span class="evidence-label">Proof Chain</span>
|
||||
<a
|
||||
[routerLink]="['/evidence/proofs']"
|
||||
[queryParams]="{ evidenceId: deployment().evidenceId, returnTo: buildReturnToUrl() }"
|
||||
class="rekor-link"
|
||||
>
|
||||
Open proof chains
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -330,6 +339,7 @@ interface DeploymentArtifact {
|
||||
.header-meta strong { color: var(--color-text-primary); }
|
||||
.header-meta code { font-size: 0.625rem; }
|
||||
.header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.action-status { width: 100%; margin: 0; font-size: 0.75rem; color: var(--color-text-secondary); }
|
||||
|
||||
.status-badge { padding: 0.25rem 0.75rem; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-semibold); }
|
||||
.status-badge--running { background: var(--color-severity-info-bg); color: var(--color-status-info-text); }
|
||||
@@ -451,6 +461,7 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
||||
@ViewChild('logViewer') logViewerRef?: ElementRef<HTMLDivElement>;
|
||||
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
deploymentId = signal('');
|
||||
activeTab = signal('workflow');
|
||||
@@ -458,6 +469,7 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
||||
selectedLogStep = signal('all');
|
||||
logSearchQuery = signal('');
|
||||
autoScroll = signal(false);
|
||||
actionMessage = signal<string | null>(null);
|
||||
|
||||
tabs = [
|
||||
{ id: 'workflow', label: 'Workflow' },
|
||||
@@ -604,17 +616,45 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
||||
}
|
||||
|
||||
// DEP-004: Artifact methods
|
||||
copyHash(hash: string): void {
|
||||
navigator.clipboard.writeText(hash);
|
||||
console.log('Copied hash:', hash);
|
||||
async copyHash(hash: string): Promise<void> {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(hash);
|
||||
} else if (!this.copyWithExecCommand(hash)) {
|
||||
throw new Error('Clipboard unavailable');
|
||||
}
|
||||
this.actionMessage.set(`Copied ${hash.slice(0, 16)}...`);
|
||||
} catch {
|
||||
if (this.copyWithExecCommand(hash)) {
|
||||
this.actionMessage.set(`Copied ${hash.slice(0, 16)}...`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionMessage.set(`Hash ready: ${hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
viewArtifact(artifact: DeploymentArtifact): void {
|
||||
console.log('View artifact:', artifact.name);
|
||||
const payload = this.buildArtifactPayload(artifact);
|
||||
const blob = new Blob([payload.content], { type: payload.contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const popup = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (!popup) {
|
||||
this.downloadBlob(blob, payload.fileName);
|
||||
this.actionMessage.set(`Downloaded ${artifact.name} because a new tab could not be opened.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionMessage.set(`Opened ${artifact.name}.`);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
downloadArtifact(artifact: DeploymentArtifact): void {
|
||||
console.log('Download artifact:', artifact.name);
|
||||
const payload = this.buildArtifactPayload(artifact);
|
||||
const blob = new Blob([payload.content], { type: payload.contentType });
|
||||
this.downloadBlob(blob, payload.fileName);
|
||||
this.actionMessage.set(`Downloaded ${artifact.name}.`);
|
||||
}
|
||||
|
||||
// DEP-005: Logs methods
|
||||
@@ -631,7 +671,9 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
||||
}
|
||||
|
||||
downloadLogs(): void {
|
||||
console.log('Download logs');
|
||||
const blob = new Blob([this.filteredLogs()], { type: 'text/plain;charset=utf-8' });
|
||||
this.downloadBlob(blob, `${this.deployment().id.toLowerCase()}-logs.txt`);
|
||||
this.actionMessage.set(`Downloaded logs for ${this.deployment().id}.`);
|
||||
}
|
||||
|
||||
getLogLineCount(): number {
|
||||
@@ -647,14 +689,133 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
||||
|
||||
// Header actions
|
||||
openEvidence(): void {
|
||||
console.log('Open evidence:', this.deployment().evidenceId);
|
||||
}
|
||||
|
||||
rollback(): void {
|
||||
console.log('Rollback deployment:', this.deployment().id);
|
||||
this.activeTab.set('evidence');
|
||||
this.actionMessage.set(`Opened evidence summary for ${this.deployment().id}.`);
|
||||
}
|
||||
|
||||
replayVerify(): void {
|
||||
console.log('Replay verify deployment:', this.deployment().id);
|
||||
void this.router.navigate(['/evidence/verify-replay'], {
|
||||
queryParams: {
|
||||
releaseId: this.deployment().releaseVersion,
|
||||
returnTo: this.buildReturnToUrl(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
buildReturnToUrl(): string {
|
||||
return this.router.serializeUrl(
|
||||
this.router.createUrlTree(['/releases/deployments', this.deployment().id]),
|
||||
);
|
||||
}
|
||||
|
||||
private buildArtifactPayload(artifact: DeploymentArtifact): {
|
||||
content: string;
|
||||
contentType: string;
|
||||
fileName: string;
|
||||
} {
|
||||
switch (artifact.type) {
|
||||
case 'lock':
|
||||
return {
|
||||
fileName: artifact.name,
|
||||
contentType: 'application/yaml;charset=utf-8',
|
||||
content: [
|
||||
'services:',
|
||||
' web:',
|
||||
` image: registry.example.com/stella/web:${this.deployment().releaseVersion}`,
|
||||
` digest: ${this.deployment().bundleDigest}`,
|
||||
`deploymentId: ${this.deployment().id}`,
|
||||
`environment: ${this.deployment().environment}`,
|
||||
].join('\n'),
|
||||
};
|
||||
case 'script':
|
||||
return {
|
||||
fileName: artifact.name,
|
||||
contentType: 'text/plain;charset=utf-8',
|
||||
content: [
|
||||
'# StellaOps deterministic deployment replay manifest',
|
||||
`deployment=${this.deployment().id}`,
|
||||
`release=${this.deployment().releaseVersion}`,
|
||||
`planHash=${this.deployment().planHash}`,
|
||||
`agent=${this.deployment().agentId}`,
|
||||
].join('\n'),
|
||||
};
|
||||
case 'evidence':
|
||||
return {
|
||||
fileName: artifact.name,
|
||||
contentType: 'application/json;charset=utf-8',
|
||||
content: JSON.stringify(
|
||||
{
|
||||
evidenceId: this.deployment().evidenceId,
|
||||
deploymentId: this.deployment().id,
|
||||
releaseVersion: this.deployment().releaseVersion,
|
||||
environment: this.deployment().environment,
|
||||
verified: true,
|
||||
signed: true,
|
||||
artifactHash: artifact.hash,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
case 'manifest':
|
||||
return {
|
||||
fileName: artifact.name,
|
||||
contentType: 'application/json;charset=utf-8',
|
||||
content: JSON.stringify(
|
||||
{
|
||||
deploymentId: this.deployment().id,
|
||||
bundleDigest: this.deployment().bundleDigest,
|
||||
releaseVersion: this.deployment().releaseVersion,
|
||||
environment: this.deployment().environment,
|
||||
initiatedBy: this.deployment().initiatedBy,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
case 'config':
|
||||
default:
|
||||
return {
|
||||
fileName: artifact.name,
|
||||
contentType: 'application/json;charset=utf-8',
|
||||
content: JSON.stringify(
|
||||
{
|
||||
deploymentId: this.deployment().id,
|
||||
artifact: artifact.name,
|
||||
hash: artifact.hash,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private downloadBlob(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.rel = 'noopener';
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
private copyWithExecCommand(value: string): boolean {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
return document.execCommand('copy');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ interface Deployment {
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a>
|
||||
<span class="release-version">{{ deployment.releaseVersion }}</span>
|
||||
</td>
|
||||
<td>{{ deployment.environment }}</td>
|
||||
<td>
|
||||
@@ -96,7 +96,7 @@ interface Deployment {
|
||||
<div>
|
||||
<dt>Release</dt>
|
||||
<dd>
|
||||
<a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a>
|
||||
<span class="release-version">{{ deployment.releaseVersion }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -145,6 +145,7 @@ interface Deployment {
|
||||
.data-table a { color: var(--color-brand-primary); text-decoration: none; }
|
||||
|
||||
.deployment-link { font-family: ui-monospace, SFMono-Regular, monospace; font-weight: var(--font-weight-medium); }
|
||||
.release-version { font-family: ui-monospace, SFMono-Regular, monospace; color: var(--color-text-primary); }
|
||||
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.125rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.625rem; font-weight: var(--font-weight-semibold); }
|
||||
.status-badge--running { background: var(--color-severity-info-bg); color: var(--color-status-info-text); }
|
||||
|
||||
@@ -68,6 +68,14 @@ describe('RELEASES_ROUTES', () => {
|
||||
expect(paths).toContain('promotions');
|
||||
});
|
||||
|
||||
it('should mount deployments as a lazy child route tree', () => {
|
||||
const deploymentsRoute = RELEASES_ROUTES.find((r) => r.path === 'deployments');
|
||||
expect(deploymentsRoute).toBeDefined();
|
||||
expect(typeof deploymentsRoute!.loadChildren).toBe('function');
|
||||
expect(deploymentsRoute!.loadComponent).toBeUndefined();
|
||||
expect(deploymentsRoute!.title).toBe('Deployment History');
|
||||
});
|
||||
|
||||
it('should use loadChildren for lazy-loaded investigation routes', () => {
|
||||
const investigationPaths = [
|
||||
'investigation/timeline',
|
||||
|
||||
@@ -202,8 +202,8 @@ export const RELEASES_ROUTES: Routes = [
|
||||
path: 'deployments',
|
||||
title: 'Deployment History',
|
||||
data: { breadcrumb: 'Deployments' },
|
||||
loadComponent: () =>
|
||||
import('../features/deployments/deployments-list-page.component').then((m) => m.DeploymentsListPageComponent),
|
||||
loadChildren: () =>
|
||||
import('../features/deployments/deployments.routes').then((m) => m.DEPLOYMENTS_ROUTES),
|
||||
},
|
||||
// --- Release investigation routes (Sprint 022) ---
|
||||
// The investigation timeline is mounted as a bounded secondary route under
|
||||
|
||||
@@ -3,10 +3,12 @@ import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DeploymentDetailPageComponent } from '../../app/features/deployments/deployment-detail-page.component';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
describe('DeploymentDetailPageComponent (deployment detail)', () => {
|
||||
let fixture: ComponentFixture<DeploymentDetailPageComponent>;
|
||||
let component: DeploymentDetailPageComponent;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -24,6 +26,7 @@ describe('DeploymentDetailPageComponent (deployment detail)', () => {
|
||||
|
||||
fixture = TestBed.createComponent(DeploymentDetailPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
router = TestBed.inject(Router);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -60,4 +63,37 @@ describe('DeploymentDetailPageComponent (deployment detail)', () => {
|
||||
expect(() => component.getMatchCount()).not.toThrow();
|
||||
expect(component.getMatchCount()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('opens the local evidence tab and routes replay into the canonical replay surface', () => {
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
component.openEvidence();
|
||||
component.replayVerify();
|
||||
|
||||
expect(component.activeTab()).toBe('evidence');
|
||||
expect(component.actionMessage()).toContain('Opened evidence summary');
|
||||
expect(navigateSpy).toHaveBeenCalledWith(
|
||||
['/evidence/verify-replay'],
|
||||
{
|
||||
queryParams: {
|
||||
releaseId: 'v1.2.5',
|
||||
returnTo: '/releases/deployments/DEP-UNIT-1',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads artifacts and logs instead of leaving actions inert', () => {
|
||||
const createObjectURLSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
|
||||
const anchor = document.createElement('a');
|
||||
const clickSpy = spyOn(anchor, 'click');
|
||||
spyOn(document, 'createElement').and.returnValue(anchor);
|
||||
|
||||
component.downloadArtifact(component.artifacts()[0]!);
|
||||
component.downloadLogs();
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledTimes(2);
|
||||
expect(anchor.download).toBe('dep-unit-1-logs.txt');
|
||||
expect(clickSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { DeploymentsListPageComponent } from '../../app/features/deployments/deployments-list-page.component';
|
||||
|
||||
describe('DeploymentsListPageComponent (deployment list)', () => {
|
||||
let fixture: ComponentFixture<DeploymentsListPageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DeploymentsListPageComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DeploymentsListPageComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('keeps deployment detail links but does not render dead release detail anchors', () => {
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const firstRowReleaseCell = host.querySelector('tbody tr td:nth-child(2)');
|
||||
const firstCardReleaseValue = host.querySelector('.deployment-card__meta dd');
|
||||
const deploymentLinks = host.querySelectorAll('a.deployment-link');
|
||||
|
||||
expect(deploymentLinks.length).toBeGreaterThan(0);
|
||||
expect(firstRowReleaseCell?.querySelector('a')).toBeNull();
|
||||
expect(firstRowReleaseCell?.textContent).toContain('v1.3.0');
|
||||
expect(firstCardReleaseValue?.querySelector('a')).toBeNull();
|
||||
expect(firstCardReleaseValue?.textContent).toContain('v1.3.0');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user