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>
469 lines
18 KiB
TypeScript
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');
|
|
});
|
|
});
|