Files
git.stella-ops.org/src/Web/StellaOps.Web/e2e/release-workflow.e2e.spec.ts
master 9c79b00598 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>
2026-03-30 17:25:19 +03:00

469 lines
18 KiB
TypeScript

/**
* 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');
});
});