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