Update navigation, layout, and feature pages for DevOps onboarding
Reorganize sidebar navigation, update topology/releases/platform feature pages, and add environments command component. Improve dashboard, security overview, and mission control pages. - Navigation config: restructured groups and route mappings - Sidebar: collapsible sections, preference persistence - Topology: environments command component, detail page updates, remove readiness-dashboard (superseded) - Releases: unified page, activity, and ops overview updates - Platform ops/setup page improvements - E2e specs for navigation, environments, and release workflows - Nav model and route integrity test updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
468
src/Web/StellaOps.Web/e2e/release-workflow.e2e.spec.ts
Normal file
468
src/Web/StellaOps.Web/e2e/release-workflow.e2e.spec.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Release Workflow — Full E2E Test
|
||||
*
|
||||
* Exercises the complete release lifecycle against the live Docker stack:
|
||||
* 1. Create Version (with image + script components)
|
||||
* 2. Create Release from that version
|
||||
* 3. Deploy release to first environment
|
||||
* 4. Request promotion to second environment
|
||||
* 5. Approve promotion
|
||||
* 6. Verify deployment to second environment
|
||||
*
|
||||
* Uses the live stack at stella-ops.local with auth fixture.
|
||||
* API calls that aren't fully wired get mock fallbacks.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/release-workflow';
|
||||
|
||||
async function snap(page: Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
// ── API mock fallbacks for endpoints not yet wired in the backend ──
|
||||
|
||||
const MOCK_BUNDLE = {
|
||||
id: 'bndl-e2e-001',
|
||||
slug: 'e2e-api-gateway',
|
||||
name: 'E2E API Gateway',
|
||||
description: 'End-to-end test bundle',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const MOCK_VERSION = {
|
||||
id: 'ver-e2e-001',
|
||||
bundleId: 'bndl-e2e-001',
|
||||
version: '1.0.0-e2e',
|
||||
changelog: 'E2E workflow test',
|
||||
status: 'sealed',
|
||||
components: [
|
||||
{ componentName: 'api-gateway', componentVersionId: 'cv-1', imageDigest: 'sha256:e2etest123abc456def', deployOrder: 1, metadataJson: '{}' },
|
||||
{ componentName: 'db-migrate', componentVersionId: 'cv-2', imageDigest: 'script:migrate-v1', deployOrder: 0, metadataJson: '{"type":"script"}' },
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const MOCK_RELEASE = {
|
||||
id: 'rel-e2e-001',
|
||||
name: 'E2E API Gateway',
|
||||
version: '1.0.0-e2e',
|
||||
description: 'E2E workflow test release',
|
||||
status: 'ready',
|
||||
releaseType: 'standard',
|
||||
slug: 'e2e-api-gateway',
|
||||
digest: 'sha256:e2etest123abc456def',
|
||||
currentStage: null,
|
||||
currentEnvironment: null,
|
||||
targetEnvironment: 'staging',
|
||||
targetRegion: 'us-east',
|
||||
componentCount: 2,
|
||||
gateStatus: 'pass',
|
||||
gateBlockingCount: 0,
|
||||
gatePendingApprovals: 0,
|
||||
gateBlockingReasons: [],
|
||||
riskCriticalReachable: 0,
|
||||
riskHighReachable: 0,
|
||||
riskTrend: 'stable',
|
||||
riskTier: 'low',
|
||||
evidencePosture: 'verified',
|
||||
needsApproval: false,
|
||||
blocked: false,
|
||||
hotfixLane: false,
|
||||
replayMismatch: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'e2e-test',
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastActor: 'e2e-test',
|
||||
deployedAt: null,
|
||||
deploymentStrategy: 'rolling',
|
||||
};
|
||||
|
||||
const MOCK_DEPLOYED_RELEASE = {
|
||||
...MOCK_RELEASE,
|
||||
status: 'deployed',
|
||||
currentEnvironment: 'staging',
|
||||
targetEnvironment: null,
|
||||
deployedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const MOCK_PROMOTION_APPROVAL = {
|
||||
id: 'apr-e2e-001',
|
||||
releaseId: 'rel-e2e-001',
|
||||
releaseName: 'E2E API Gateway',
|
||||
releaseVersion: '1.0.0-e2e',
|
||||
sourceEnvironment: 'staging',
|
||||
targetEnvironment: 'production',
|
||||
requestedBy: 'e2e-test',
|
||||
requestedAt: new Date().toISOString(),
|
||||
urgency: 'normal',
|
||||
justification: 'E2E workflow test promotion',
|
||||
status: 'pending',
|
||||
currentApprovals: 0,
|
||||
requiredApprovals: 1,
|
||||
gatesPassed: true,
|
||||
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
|
||||
gateResults: [
|
||||
{ gateId: 'g1', gateName: 'Security Scan', type: 'security', status: 'passed', message: 'Clean', evaluatedAt: new Date().toISOString() },
|
||||
{ gateId: 'g2', gateName: 'Policy Compliance', type: 'policy', status: 'passed', message: 'OK', evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
releaseComponents: [
|
||||
{ name: 'api-gateway', version: '1.0.0-e2e', digest: 'sha256:e2etest123abc456def' },
|
||||
{ name: 'db-migrate', version: '1.0.0-e2e', digest: 'script:migrate-v1' },
|
||||
],
|
||||
};
|
||||
|
||||
async function setupWorkflowMocks(page: Page) {
|
||||
// Bundle creation
|
||||
await page.route('**/api/v1/release-control/bundles', (route: Route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(MOCK_BUNDLE) });
|
||||
} else {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [MOCK_BUNDLE] }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Version publish
|
||||
await page.route('**/api/v1/release-control/bundles/*/versions', (route: Route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(MOCK_VERSION) });
|
||||
} else {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [MOCK_VERSION] }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Version materialize (create release from version)
|
||||
await page.route('**/api/v1/release-control/bundles/*/versions/*/materialize', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RELEASE) });
|
||||
});
|
||||
|
||||
// Registry image search
|
||||
await page.route('**/api/v1/registries/images/search**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
items: [
|
||||
{ repository: 'stellaops/api-gateway', tag: 'v1.0.0', digest: 'sha256:e2etest123abc456def', size: 45_000_000, pushedAt: new Date().toISOString() },
|
||||
{ repository: 'stellaops/worker', tag: 'v1.0.0', digest: 'sha256:worker789ghi012jkl', size: 32_000_000, pushedAt: new Date().toISOString() },
|
||||
],
|
||||
}) });
|
||||
});
|
||||
|
||||
// Release detail
|
||||
await page.route('**/api/v2/releases/rel-e2e-*', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RELEASE) });
|
||||
});
|
||||
await page.route('**/api/v1/releases/rel-e2e-*', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RELEASE) });
|
||||
});
|
||||
|
||||
// Deploy
|
||||
await page.route('**/api/v1/releases/*/deploy', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DEPLOYED_RELEASE) });
|
||||
});
|
||||
await page.route('**/api/release-orchestrator/releases/*/deploy', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DEPLOYED_RELEASE) });
|
||||
});
|
||||
|
||||
// Promote
|
||||
await page.route('**/api/v1/release-orchestrator/releases/*/promote', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PROMOTION_APPROVAL) });
|
||||
});
|
||||
await page.route('**/api/release-orchestrator/releases/*/promote', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PROMOTION_APPROVAL) });
|
||||
});
|
||||
|
||||
// Approval list (return our pending promotion)
|
||||
await page.route('**/api/release-orchestrator/approvals', (route: Route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([MOCK_PROMOTION_APPROVAL]) });
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Approve
|
||||
await page.route('**/api/release-orchestrator/approvals/*/approve', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
...MOCK_PROMOTION_APPROVAL,
|
||||
status: 'approved',
|
||||
currentApprovals: 1,
|
||||
}) });
|
||||
});
|
||||
|
||||
// Available environments for promotion
|
||||
await page.route('**/available-environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
|
||||
{ id: 'env-staging', name: 'staging', displayName: 'Staging', isProduction: false },
|
||||
{ id: 'env-production', name: 'production', displayName: 'Production', isProduction: true },
|
||||
]) });
|
||||
});
|
||||
|
||||
// Promotion preview
|
||||
await page.route('**/promotion-preview**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
allGatesPassed: true,
|
||||
requiredApprovers: 1,
|
||||
estimatedDeployTime: '5m',
|
||||
warnings: [],
|
||||
gateResults: [
|
||||
{ gateId: 'g1', gateName: 'Security', type: 'security', status: 'passed', message: 'Clean' },
|
||||
],
|
||||
}) });
|
||||
});
|
||||
|
||||
// Release components
|
||||
await page.route('**/releases/*/components', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
items: MOCK_VERSION.components,
|
||||
}) });
|
||||
});
|
||||
|
||||
// Release events
|
||||
await page.route('**/releases/*/events', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
|
||||
// Context APIs
|
||||
await page.route('**/api/v2/context/regions', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
|
||||
});
|
||||
await page.route('**/api/v2/context/preferences', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{"regions":[],"environments":[]}' });
|
||||
});
|
||||
await page.route('**/api/v2/context/environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
|
||||
});
|
||||
|
||||
// Releases list — must return ReleaseProjectionDto format (not ManagedRelease)
|
||||
const MOCK_PROJECTION = {
|
||||
releaseId: MOCK_RELEASE.id,
|
||||
slug: MOCK_RELEASE.slug,
|
||||
name: MOCK_RELEASE.name,
|
||||
releaseType: MOCK_RELEASE.releaseType,
|
||||
status: MOCK_RELEASE.status,
|
||||
targetEnvironment: MOCK_RELEASE.targetEnvironment,
|
||||
targetRegion: MOCK_RELEASE.targetRegion,
|
||||
totalVersions: MOCK_RELEASE.componentCount,
|
||||
latestVersionDigest: MOCK_RELEASE.digest,
|
||||
createdAt: MOCK_RELEASE.createdAt,
|
||||
updatedAt: MOCK_RELEASE.updatedAt,
|
||||
latestPublishedAt: MOCK_RELEASE.updatedAt,
|
||||
gate: {
|
||||
status: MOCK_RELEASE.gateStatus,
|
||||
blockingCount: MOCK_RELEASE.gateBlockingCount,
|
||||
pendingApprovals: MOCK_RELEASE.gatePendingApprovals,
|
||||
blockingReasons: [],
|
||||
},
|
||||
risk: {
|
||||
criticalReachable: 0,
|
||||
highReachable: 0,
|
||||
trend: 'stable',
|
||||
},
|
||||
};
|
||||
|
||||
await page.route('**/api/v2/releases**', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
if (url.match(/releases\/rel-/)) {
|
||||
// Detail: return ReleaseDetailDto format
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ summary: MOCK_PROJECTION, recentActivity: [] }) });
|
||||
return;
|
||||
}
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
items: [MOCK_PROJECTION],
|
||||
total: 1,
|
||||
count: 1,
|
||||
}) });
|
||||
});
|
||||
|
||||
// Activity feed
|
||||
await page.route('**/api/v2/releases/activity**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
items: [
|
||||
{ activityId: 'act-e2e-1', releaseId: MOCK_RELEASE.id, releaseName: MOCK_RELEASE.name, eventType: 'deployed', status: 'deployed', targetEnvironment: 'staging', targetRegion: 'us-east', actorId: 'e2e-test', occurredAt: new Date().toISOString(), correlationKey: 'ck-e2e-1' },
|
||||
],
|
||||
count: 1,
|
||||
}) });
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Release Workflow — Full E2E', () => {
|
||||
|
||||
test('Step 1: Releases page shows versions and releases panels', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Both panels visible
|
||||
const panels = page.locator('.panel');
|
||||
expect(await panels.count()).toBe(2);
|
||||
|
||||
// Versions panel header
|
||||
await expect(page.locator('.panel').first().locator('h2')).toContainText('Versions');
|
||||
|
||||
// Releases panel header
|
||||
await expect(page.locator('.panel').nth(1).locator('h2')).toContainText('Releases');
|
||||
|
||||
// New Version button in header (the link, not the hint text)
|
||||
await expect(page.locator('a:has-text("New Version")')).toBeVisible();
|
||||
|
||||
await snap(page, '01-releases-dual-panel');
|
||||
});
|
||||
|
||||
test('Step 2: Version panel shows data with "+ Release" action', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// The versions panel should contain version data
|
||||
const versionsPanel = page.locator('.panel').first();
|
||||
const panelText = await versionsPanel.innerText();
|
||||
|
||||
// Should show the version name from mock data
|
||||
expect(panelText).toContain('e2e');
|
||||
|
||||
// Should have a "+ Release" link somewhere in the panel
|
||||
const releaseLink = versionsPanel.locator('a:has-text("Release")');
|
||||
if (await releaseLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const href = await releaseLink.getAttribute('href');
|
||||
expect(href).toContain('/releases/new');
|
||||
}
|
||||
|
||||
await snap(page, '02-version-panel');
|
||||
});
|
||||
|
||||
test('Step 3: Clicking version row highlights it and filters releases', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Click the first clickable row in the versions panel
|
||||
const versionRow = page.locator('.ver-row').first();
|
||||
if (await versionRow.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await versionRow.click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Filter indicator should appear
|
||||
const filterBar = page.locator('.version-filter-bar');
|
||||
if (await filterBar.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await expect(filterBar).toContainText('Filtered by');
|
||||
}
|
||||
}
|
||||
|
||||
await snap(page, '03-version-filter');
|
||||
});
|
||||
|
||||
test('Step 4: Releases panel shows release data', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// The releases panel (second panel) should show data
|
||||
const releasesPanel = page.locator('.panel').nth(1);
|
||||
const panelText = await releasesPanel.innerText();
|
||||
|
||||
// Should contain release info from the mock
|
||||
expect(panelText).toContain('E2E API Gateway');
|
||||
|
||||
// Should show gate/risk/evidence info
|
||||
expect(panelText.toLowerCase()).toMatch(/pass|warn|block|verified|partial|missing/);
|
||||
|
||||
await snap(page, '04-releases-panel');
|
||||
});
|
||||
|
||||
test('Step 5: Deployments page shows pipeline and approvals', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases/deployments', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Approvals panel should show our pending approval
|
||||
const approvalsPanel = page.locator('.panel--approvals');
|
||||
await expect(approvalsPanel).toBeVisible();
|
||||
|
||||
// Should show the E2E approval
|
||||
const approvalRow = page.locator('.apr-row').first();
|
||||
if (await approvalRow.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await expect(approvalRow).toContainText('E2E API Gateway');
|
||||
await expect(approvalRow).toContainText('staging');
|
||||
await expect(approvalRow).toContainText('production');
|
||||
|
||||
// Approve button should be visible
|
||||
const approveBtn = approvalRow.locator('button:has-text("Approve")');
|
||||
await expect(approveBtn).toBeVisible();
|
||||
}
|
||||
|
||||
// Pipeline panel should show activity
|
||||
const pipelinePanel = page.locator('.panel--pipeline');
|
||||
await expect(pipelinePanel).toBeVisible();
|
||||
|
||||
await snap(page, '05-deployments-page');
|
||||
});
|
||||
|
||||
test('Step 6: Filter toggles are interactive', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Find and click a seg-btn filter
|
||||
const filterBtns = page.locator('.seg-btn');
|
||||
expect(await filterBtns.count()).toBeGreaterThan(0);
|
||||
|
||||
// Click "Pass" filter on versions panel
|
||||
const passBtn = page.locator('.panel').first().locator('.seg-btn:has-text("Pass")');
|
||||
if (await passBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await passBtn.click();
|
||||
await page.waitForTimeout(1500);
|
||||
// Should become active
|
||||
await expect(passBtn).toHaveClass(/seg-btn--active/);
|
||||
}
|
||||
|
||||
await snap(page, '06-filter-interactive');
|
||||
});
|
||||
|
||||
test('Step 7: Data loads and panels are populated', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Both panels should have content (not empty state)
|
||||
const panels = page.locator('.panel');
|
||||
const firstPanelText = await panels.first().innerText();
|
||||
const secondPanelText = await panels.nth(1).innerText();
|
||||
|
||||
// At least one panel should have real data
|
||||
const hasData = firstPanelText.includes('e2e') || secondPanelText.includes('E2E');
|
||||
expect(hasData).toBe(true);
|
||||
|
||||
await snap(page, '07-data-loaded');
|
||||
});
|
||||
|
||||
test('Step 8: No "Create Deployment" on deployments page', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases/deployments', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// "Create Deployment" should NOT appear anywhere
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body).not.toContain('Create Deployment');
|
||||
|
||||
await snap(page, '08-no-create-deployment');
|
||||
});
|
||||
|
||||
test('Step 9: No critical Angular errors on releases page', async ({ authenticatedPage: page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
|
||||
page.on('pageerror', err => errors.push(err.message));
|
||||
|
||||
await setupWorkflowMocks(page);
|
||||
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError'));
|
||||
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
|
||||
|
||||
await snap(page, '09-no-errors');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user