Repair live releases deployment detail flows
This commit is contained in:
@@ -7,11 +7,11 @@ Web
|
|||||||
VERIFIED
|
VERIFIED
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
Deployment detail page with workflow DAG visualization showing deployment step execution, artifact promotion flow, and gate evaluation results.
|
Read-only deployment detail page under the canonical `/releases/deployments/:deploymentId` host, with workflow DAG visualization, artifact/log inspection, and evidence/replay hand-offs that stay truthful to the current live backend contract.
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/deployments/`
|
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/deployments/`
|
||||||
- **Routes**: `deployments.routes.ts`
|
- **Routes**: `src/app/routes/releases.routes.ts` mounts `features/deployments/deployments.routes.ts` under `/releases/deployments`
|
||||||
- **Components**:
|
- **Components**:
|
||||||
- `deployment-detail-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts`)
|
- `deployment-detail-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts`)
|
||||||
- `deployments-list-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts`)
|
- `deployments-list-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts`)
|
||||||
@@ -49,3 +49,9 @@ Deployment detail page with workflow DAG visualization showing deployment step e
|
|||||||
- Status: PASSED (strict Tier 2 UI replay)
|
- Status: PASSED (strict Tier 2 UI replay)
|
||||||
- Tier 2 evidence: docs/qa/feature-checks/runs/web/deployment-detail-with-workflow-dag-visualization/run-004/tier2-ui-check.json
|
- Tier 2 evidence: docs/qa/feature-checks/runs/web/deployment-detail-with-workflow-dag-visualization/run-004/tier2-ui-check.json
|
||||||
- Notes: Verified via /release-jobengine/deployments/dep-001 workflow DAG node rendering and selection checks.
|
- Notes: Verified via /release-jobengine/deployments/dep-001 workflow DAG node rendering and selection checks.
|
||||||
|
|
||||||
|
## Recheck (run-005)
|
||||||
|
- Date (UTC): 2026-03-08T22:06:32Z
|
||||||
|
- Status: VERIFIED (strict live Playwright replay)
|
||||||
|
- Tier 2 evidence: `src/Web/StellaOps.Web/output/playwright/live-releases-deployments-check.json`
|
||||||
|
- Notes: Verified the canonical Releases detail route at `/releases/deployments/DEP-2026-050`, workflow/targets/artifacts/logs/evidence tabs, and the repaired detail actions. The page remains intentionally read-only until the live deployment operate API is available again.
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ Web
|
|||||||
VERIFIED
|
VERIFIED
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
Real-time deployment monitoring with per-target progress tracking, live log streaming, deployment actions (pause/resume/cancel), and rollback capabilities.
|
Read-only deployment monitoring in the canonical Releases shell with per-target progress tracking, workflow/log inspection, artifact export, and evidence/replay hand-offs. Live operate and rollback controls remain deferred until the deployment API is restored.
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/deployments/`
|
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/deployments/`
|
||||||
- **Routes**: `deployments.routes.ts`
|
- **Routes**: `src/app/routes/releases.routes.ts` mounts `features/deployments/deployments.routes.ts` under `/releases/deployments`
|
||||||
- **Components**:
|
- **Components**:
|
||||||
- `deployment-detail-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts`)
|
- `deployment-detail-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts`)
|
||||||
- `deployments-list-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts`)
|
- `deployments-list-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts`)
|
||||||
@@ -43,3 +43,9 @@ Real-time deployment monitoring with per-target progress tracking, live log stre
|
|||||||
- Status: VERIFIED (strict Tier 2 UI replay)
|
- Status: VERIFIED (strict Tier 2 UI replay)
|
||||||
- Tier 2 evidence: docs/qa/feature-checks/runs/web/deployment-monitoring-ui/run-003/tier2-ui-check.json.
|
- Tier 2 evidence: docs/qa/feature-checks/runs/web/deployment-monitoring-ui/run-003/tier2-ui-check.json.
|
||||||
|
|
||||||
|
## Recheck (run-004)
|
||||||
|
- Date (UTC): 2026-03-08T22:06:32Z
|
||||||
|
- Status: VERIFIED (strict live Playwright replay)
|
||||||
|
- Tier 2 evidence: `src/Web/StellaOps.Web/output/playwright/live-releases-deployments-check.json`
|
||||||
|
- Notes: Verified canonical `/releases/deployments/:deploymentId` render, removed dead release-version anchors from the list, confirmed header replay hand-off, local evidence-tab hand-off, artifact hash copy fallback, artifact view/download fallback, log download, and evidence/proof-chain route entry points. Rollback is intentionally absent from the mounted surface while the live deployment operate API remains unavailable.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Sprint 20260308-026 - FE Live Releases Deployments Route And Action Repair
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Repair the canonical `/releases/deployments` subtree so deployment detail routes render under the Releases shell instead of falling through to unrelated fallback content.
|
||||||
|
- Remove or replace dead actions inside the currently mounted deployment history/detail surfaces so visible UI affordances are either functional or explicitly not presented.
|
||||||
|
- Keep the repair inside the active Web shell and document the live contract boundary while the legacy deployment API remains unavailable.
|
||||||
|
- Working directory: `src/Web/StellaOps.Web`.
|
||||||
|
- Allowed coordination edits: `docs/features/checked/web/`, `docs/modules/ui/orphan-revival-batch/README.md`, `docs/modules/ui/TASKS.md`, `src/Web/StellaOps.Web/src/app/routes/releases.routes.ts`, `src/Web/StellaOps.Web/src/app/features/deployments/`, `src/Web/StellaOps.Web/scripts/`.
|
||||||
|
- Expected evidence: targeted Angular coverage, rebuilt web bundle synced to the compose frontdoor volume, and live Playwright verification against `https://stella-ops.local`.
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Depends on the current Releases shell remaining canonical for deployment history under `/releases`.
|
||||||
|
- Safe parallelism: avoid unrelated search, package, and setup areas; keep edits limited to the releases route tree and `features/deployments`.
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `src/Web/StellaOps.Web/AGENTS.md`
|
||||||
|
- `docs/modules/platform/architecture-overview.md`
|
||||||
|
- `docs/technical/architecture/console-admin-rbac.md`
|
||||||
|
- `docs/technical/architecture/console-branding.md`
|
||||||
|
- `docs/modules/ui/orphan-revival-batch/README.md`
|
||||||
|
- `docs/features/checked/web/deployment-monitoring-ui.md`
|
||||||
|
- `docs/features/checked/web/deployment-detail-with-workflow-dag-visualization.md`
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
### FE-LIVE-DEP-001 - Reconnect canonical Releases deployment detail routing
|
||||||
|
Status: DONE
|
||||||
|
Dependency: none
|
||||||
|
Owners: Developer (FE), QA
|
||||||
|
Task description:
|
||||||
|
- Mount the full deployments route tree under `/releases/deployments` so detail URLs resolve inside the canonical Releases shell.
|
||||||
|
- Verify that the deployment list no longer links to unreachable routes or invalid release-version URLs.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] `/releases/deployments/:deploymentId` renders the deployment detail workspace instead of fallback content.
|
||||||
|
- [x] Deployment list actions do not point at non-existent `/releases/:version` routes.
|
||||||
|
- [x] Route-focused regression coverage exists for the canonical Releases mount.
|
||||||
|
|
||||||
|
### FE-LIVE-DEP-002 - Make deployment detail actions functional or remove them
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-LIVE-DEP-001
|
||||||
|
Owners: Developer (FE), Product Manager
|
||||||
|
Task description:
|
||||||
|
- Replace console-log-only deployment detail actions with real operator flows where safe, and remove misleading actions where no live contract exists yet.
|
||||||
|
- Keep the detail workspace internally consistent in the canonical `/releases` host even while the legacy deployment API remains unavailable.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Visible deployment detail actions either navigate/download/copy successfully or are no longer shown.
|
||||||
|
- [x] Legacy `/deployments` path assumptions are removed from the mounted detail workspace.
|
||||||
|
- [x] Checked-feature documentation records the repaired live contract and any intentionally deferred capability.
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-03-08 | Sprint created after live Playwright found `/releases/deployments/:deploymentId` falling through to fallback content and the deployment list generating dead release links. | Developer (FE) |
|
||||||
|
| 2026-03-08 | Re-mounted `/releases/deployments` as the full lazy route tree, removed dead release-version anchors from the list surface, and added route/list regression coverage. | Developer (FE) |
|
||||||
|
| 2026-03-08 | Replaced console-log-only detail actions with real evidence-tab, replay, proof-chain, artifact, and log flows; removed rollback from the mounted UI because the live deployment operate API is still absent. | Developer (FE) |
|
||||||
|
| 2026-03-08 | Verified targeted Angular coverage (`15/15`), rebuilt the web bundle, synced `dist/stellaops-web/browser` into `compose_console-dist`, and passed the live Playwright regression script `scripts/live-releases-deployments-check.mjs` against `https://stella-ops.local`. | QA |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Decision: keep the current deployment detail workspace as a bounded, read-only `/releases/deployments/:deploymentId` surface because the Releases shell still owns deployment history, but avoid implying live operate/rollback contracts that the backend does not currently provide.
|
||||||
|
- Risk: the legacy deployment HTTP API (`/api/v1/release-orchestrator/deployments`) is currently unavailable in the live stack, so this sprint must avoid binding visible routes to dead backend contracts.
|
||||||
|
- Mitigation: repair route ownership first, then keep the detail page honest about which actions are available in the current canonical host.
|
||||||
|
- Decision: `Open Evidence` now focuses the local evidence tab, while evidence/proof-chain hand-offs route to canonical `/evidence/capsules` and `/evidence/proofs` entry points instead of fabricating a capsule-detail deep link that may not exist in live data.
|
||||||
|
- Decision: the evidence workspace and proof-chain links preserve `returnTo` so operators can jump back to the deployment detail route without losing the canonical Releases host.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- 2026-03-08: route tree reconnected and live detail render verified.
|
||||||
|
- 2026-03-08: detail actions rechecked with Playwright after bundle sync.
|
||||||
@@ -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 { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit, ElementRef, ViewChild, AfterViewChecked } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
interface WorkflowStep {
|
interface WorkflowStep {
|
||||||
@@ -36,7 +36,7 @@ interface DeploymentArtifact {
|
|||||||
template: `
|
template: `
|
||||||
<div class="deployment-detail">
|
<div class="deployment-detail">
|
||||||
<header class="page-header">
|
<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-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>
|
||||||
@@ -55,15 +55,13 @@ interface DeploymentArtifact {
|
|||||||
<button type="button" class="btn btn--secondary" (click)="openEvidence()">
|
<button type="button" class="btn btn--secondary" (click)="openEvidence()">
|
||||||
Open Evidence
|
Open Evidence
|
||||||
</button>
|
</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()">
|
<button type="button" class="btn btn--secondary" (click)="replayVerify()">
|
||||||
Replay Verify
|
Replay Verify
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@if (actionMessage()) {
|
||||||
|
<p class="action-status" role="status">{{ actionMessage() }}</p>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
@@ -291,7 +289,12 @@ interface DeploymentArtifact {
|
|||||||
<h3>Deployment Evidence</h3>
|
<h3>Deployment Evidence</h3>
|
||||||
<p class="evidence-info">
|
<p class="evidence-info">
|
||||||
Evidence for this deployment is sealed and signed.
|
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>
|
</p>
|
||||||
<div class="evidence-summary">
|
<div class="evidence-summary">
|
||||||
<div class="evidence-item">
|
<div class="evidence-item">
|
||||||
@@ -307,8 +310,14 @@ interface DeploymentArtifact {
|
|||||||
<span class="evidence-badge evidence-badge--success">Yes</span>
|
<span class="evidence-badge evidence-badge--success">Yes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="evidence-item">
|
<div class="evidence-item">
|
||||||
<span class="evidence-label">Rekor Entry</span>
|
<span class="evidence-label">Proof Chain</span>
|
||||||
<a href="#" class="rekor-link">View in Rekor</a>
|
<a
|
||||||
|
[routerLink]="['/evidence/proofs']"
|
||||||
|
[queryParams]="{ evidenceId: deployment().evidenceId, returnTo: buildReturnToUrl() }"
|
||||||
|
class="rekor-link"
|
||||||
|
>
|
||||||
|
Open proof chains
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -330,6 +339,7 @@ interface DeploymentArtifact {
|
|||||||
.header-meta strong { color: var(--color-text-primary); }
|
.header-meta strong { color: var(--color-text-primary); }
|
||||||
.header-meta code { font-size: 0.625rem; }
|
.header-meta code { font-size: 0.625rem; }
|
||||||
.header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
.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 { 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); }
|
.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>;
|
@ViewChild('logViewer') logViewerRef?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
deploymentId = signal('');
|
deploymentId = signal('');
|
||||||
activeTab = signal('workflow');
|
activeTab = signal('workflow');
|
||||||
@@ -458,6 +469,7 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
|||||||
selectedLogStep = signal('all');
|
selectedLogStep = signal('all');
|
||||||
logSearchQuery = signal('');
|
logSearchQuery = signal('');
|
||||||
autoScroll = signal(false);
|
autoScroll = signal(false);
|
||||||
|
actionMessage = signal<string | null>(null);
|
||||||
|
|
||||||
tabs = [
|
tabs = [
|
||||||
{ id: 'workflow', label: 'Workflow' },
|
{ id: 'workflow', label: 'Workflow' },
|
||||||
@@ -604,17 +616,45 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DEP-004: Artifact methods
|
// DEP-004: Artifact methods
|
||||||
copyHash(hash: string): void {
|
async copyHash(hash: string): Promise<void> {
|
||||||
navigator.clipboard.writeText(hash);
|
try {
|
||||||
console.log('Copied hash:', hash);
|
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 {
|
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 {
|
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
|
// DEP-005: Logs methods
|
||||||
@@ -631,7 +671,9 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadLogs(): void {
|
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 {
|
getLogLineCount(): number {
|
||||||
@@ -647,14 +689,133 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
|
|||||||
|
|
||||||
// Header actions
|
// Header actions
|
||||||
openEvidence(): void {
|
openEvidence(): void {
|
||||||
console.log('Open evidence:', this.deployment().evidenceId);
|
this.activeTab.set('evidence');
|
||||||
}
|
this.actionMessage.set(`Opened evidence summary for ${this.deployment().id}.`);
|
||||||
|
|
||||||
rollback(): void {
|
|
||||||
console.log('Rollback deployment:', this.deployment().id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replayVerify(): void {
|
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>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a>
|
<span class="release-version">{{ deployment.releaseVersion }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ deployment.environment }}</td>
|
<td>{{ deployment.environment }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -96,7 +96,7 @@ interface Deployment {
|
|||||||
<div>
|
<div>
|
||||||
<dt>Release</dt>
|
<dt>Release</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a>
|
<span class="release-version">{{ deployment.releaseVersion }}</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -145,6 +145,7 @@ interface Deployment {
|
|||||||
.data-table a { color: var(--color-brand-primary); text-decoration: none; }
|
.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); }
|
.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 { 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); }
|
.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');
|
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', () => {
|
it('should use loadChildren for lazy-loaded investigation routes', () => {
|
||||||
const investigationPaths = [
|
const investigationPaths = [
|
||||||
'investigation/timeline',
|
'investigation/timeline',
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ export const RELEASES_ROUTES: Routes = [
|
|||||||
path: 'deployments',
|
path: 'deployments',
|
||||||
title: 'Deployment History',
|
title: 'Deployment History',
|
||||||
data: { breadcrumb: 'Deployments' },
|
data: { breadcrumb: 'Deployments' },
|
||||||
loadComponent: () =>
|
loadChildren: () =>
|
||||||
import('../features/deployments/deployments-list-page.component').then((m) => m.DeploymentsListPageComponent),
|
import('../features/deployments/deployments.routes').then((m) => m.DEPLOYMENTS_ROUTES),
|
||||||
},
|
},
|
||||||
// --- Release investigation routes (Sprint 022) ---
|
// --- Release investigation routes (Sprint 022) ---
|
||||||
// The investigation timeline is mounted as a bounded secondary route under
|
// 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 { of } from 'rxjs';
|
||||||
|
|
||||||
import { DeploymentDetailPageComponent } from '../../app/features/deployments/deployment-detail-page.component';
|
import { DeploymentDetailPageComponent } from '../../app/features/deployments/deployment-detail-page.component';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
describe('DeploymentDetailPageComponent (deployment detail)', () => {
|
describe('DeploymentDetailPageComponent (deployment detail)', () => {
|
||||||
let fixture: ComponentFixture<DeploymentDetailPageComponent>;
|
let fixture: ComponentFixture<DeploymentDetailPageComponent>;
|
||||||
let component: DeploymentDetailPageComponent;
|
let component: DeploymentDetailPageComponent;
|
||||||
|
let router: Router;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -24,6 +26,7 @@ describe('DeploymentDetailPageComponent (deployment detail)', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(DeploymentDetailPageComponent);
|
fixture = TestBed.createComponent(DeploymentDetailPageComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
|
router = TestBed.inject(Router);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,4 +63,37 @@ describe('DeploymentDetailPageComponent (deployment detail)', () => {
|
|||||||
expect(() => component.getMatchCount()).not.toThrow();
|
expect(() => component.getMatchCount()).not.toThrow();
|
||||||
expect(component.getMatchCount()).toBeGreaterThan(0);
|
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