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:
455
src/Web/StellaOps.Web/e2e/environment-detail.e2e.spec.ts
Normal file
455
src/Web/StellaOps.Web/e2e/environment-detail.e2e.spec.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Environment Detail Page — E2E Tests
|
||||
*
|
||||
* Verifies the overhauled environment detail page with 9 tabs:
|
||||
* Overview, Targets, Readiness, Runs, Agents, Security, Evidence, Drift, Data Quality.
|
||||
* Tests header, stat cards, gate grid, status badges, date formatting, and actions.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait } from './helpers/nav.helper';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
// ─── Mock data ──────────────────────────────────────────────────────
|
||||
|
||||
const ENV_ID = 'env-eu-prod';
|
||||
|
||||
const MOCK_ENVIRONMENTS = {
|
||||
items: [
|
||||
{ environmentId: ENV_ID, displayName: 'EU Production', regionId: 'eu-west', environmentType: 'production', sortOrder: 0, targetCount: 3, hostCount: 2, agentCount: 2, promotionPathCount: 1, workflowCount: 1, lastSyncAt: new Date().toISOString() },
|
||||
{ environmentId: 'env-eu-stage', displayName: 'EU Staging', regionId: 'eu-west', environmentType: 'staging', sortOrder: 1, targetCount: 2, hostCount: 1, agentCount: 1, promotionPathCount: 1, workflowCount: 1, lastSyncAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_TARGETS = {
|
||||
items: [
|
||||
{ targetId: 't-1', name: 'eu-prod-app-01', regionId: 'eu-west', environmentId: ENV_ID, hostId: 'h-1', agentId: 'a-1', targetType: 'docker_host', healthStatus: 'healthy', componentVersionId: 'cv-1', imageDigest: 'sha256:abc123def456', releaseId: 'r-1', releaseVersionId: 'rv-1', lastSyncAt: new Date(Date.now() - 300_000).toISOString() },
|
||||
{ targetId: 't-2', name: 'eu-prod-app-02', regionId: 'eu-west', environmentId: ENV_ID, hostId: 'h-1', agentId: 'a-1', targetType: 'docker_host', healthStatus: 'healthy', componentVersionId: 'cv-1', imageDigest: 'sha256:abc123def456', releaseId: 'r-1', releaseVersionId: 'rv-1', lastSyncAt: new Date(Date.now() - 600_000).toISOString() },
|
||||
{ targetId: 't-3', name: 'eu-prod-worker-01', regionId: 'eu-west', environmentId: ENV_ID, hostId: 'h-2', agentId: 'a-2', targetType: 'docker_host', healthStatus: 'unhealthy', componentVersionId: 'cv-1', imageDigest: 'sha256:xyz789', releaseId: 'r-1', releaseVersionId: 'rv-2', lastSyncAt: new Date(Date.now() - 3_600_000).toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_HOSTS = {
|
||||
items: [
|
||||
{ hostId: 'h-1', hostName: 'prod-host-alpha', regionId: 'eu-west', environmentId: ENV_ID, runtimeType: 'docker', status: 'healthy', agentId: 'a-1', targetCount: 2, lastSeenAt: new Date().toISOString() },
|
||||
{ hostId: 'h-2', hostName: 'prod-host-beta', regionId: 'eu-west', environmentId: ENV_ID, runtimeType: 'docker', status: 'degraded', agentId: 'a-2', targetCount: 1, lastSeenAt: new Date(Date.now() - 7_200_000).toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_AGENTS = {
|
||||
items: [
|
||||
{ agentId: 'a-1', agentName: 'agent-eu-01', regionId: 'eu-west', environmentId: ENV_ID, status: 'active', capabilities: ['docker', 'compose'], assignedTargetCount: 2, lastHeartbeatAt: new Date(Date.now() - 30_000).toISOString() },
|
||||
{ agentId: 'a-2', agentName: 'agent-eu-02', regionId: 'eu-west', environmentId: ENV_ID, status: 'degraded', capabilities: ['docker'], assignedTargetCount: 1, lastHeartbeatAt: new Date(Date.now() - 900_000).toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
function gate(name: string, status: string, msg: string) {
|
||||
return { gateName: name, status, message: msg, checkedAt: new Date().toISOString(), durationMs: 100 };
|
||||
}
|
||||
|
||||
const MOCK_READINESS = {
|
||||
items: [
|
||||
{ targetId: 't-1', environmentId: ENV_ID, isReady: true, evaluatedAt: new Date().toISOString(), gates: [
|
||||
gate('agent_bound', 'pass', 'OK'), gate('docker_version_ok', 'pass', 'Docker 24'), gate('docker_ping_ok', 'pass', 'OK'),
|
||||
gate('registry_pull_ok', 'pass', 'OK'), gate('vault_reachable', 'pass', 'OK'), gate('consul_reachable', 'pass', 'OK'), gate('connectivity_ok', 'pass', 'All pass'),
|
||||
]},
|
||||
{ targetId: 't-2', environmentId: ENV_ID, isReady: true, evaluatedAt: new Date().toISOString(), gates: [
|
||||
gate('agent_bound', 'pass', 'OK'), gate('docker_version_ok', 'pass', 'Docker 24'), gate('docker_ping_ok', 'pass', 'OK'),
|
||||
gate('registry_pull_ok', 'pass', 'OK'), gate('vault_reachable', 'pass', 'OK'), gate('consul_reachable', 'pass', 'OK'), gate('connectivity_ok', 'pass', 'All pass'),
|
||||
]},
|
||||
{ targetId: 't-3', environmentId: ENV_ID, isReady: false, evaluatedAt: new Date().toISOString(), gates: [
|
||||
gate('agent_bound', 'pass', 'OK'), gate('docker_version_ok', 'pass', 'Docker 24'), gate('docker_ping_ok', 'pass', 'OK'),
|
||||
gate('registry_pull_ok', 'fail', 'Connection refused'), gate('vault_reachable', 'pass', 'OK'), gate('consul_reachable', 'pass', 'OK'),
|
||||
gate('connectivity_ok', 'fail', 'registry_pull_ok failed'),
|
||||
]},
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_RUNS = {
|
||||
items: [
|
||||
{ activityId: 'run-1', releaseId: 'r-1', releaseName: 'v2.4.1', status: 'deployed', correlationKey: 'ck-1', occurredAt: new Date(Date.now() - 3_600_000).toISOString(), durationMs: 45000 },
|
||||
{ activityId: 'run-2', releaseId: 'r-2', releaseName: 'v2.4.0', status: 'failed', correlationKey: 'ck-2', occurredAt: new Date(Date.now() - 86_400_000).toISOString(), durationMs: 12000 },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_FINDINGS = {
|
||||
items: [
|
||||
{ findingId: 'f-1', cveId: 'CVE-2024-1234', severity: 'critical', effectiveDisposition: 'action_required', cvss: 9.8, reachable: true },
|
||||
{ findingId: 'f-2', cveId: 'CVE-2024-5678', severity: 'medium', effectiveDisposition: 'not_affected', cvss: 5.3, reachable: false },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_CAPSULES = {
|
||||
items: [
|
||||
{ capsuleId: 'cap-abc123def456', status: 'complete', updatedAt: new Date(Date.now() - 7_200_000).toISOString(), signatureStatus: 'signed', contentTypes: ['SBOM', 'Attestation'] },
|
||||
{ capsuleId: 'cap-xyz789ghi012', status: 'stale', updatedAt: new Date(Date.now() - 604_800_000).toISOString(), signatureStatus: 'verified', contentTypes: ['Log'] },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_PROMOTION_PATHS = {
|
||||
items: [
|
||||
{ pathId: 'pp-1', regionId: 'eu-west', sourceEnvironmentId: 'env-eu-stage', targetEnvironmentId: ENV_ID, pathMode: 'auto', status: 'active', requiredApprovals: 0, workflowId: 'w-1', gateProfileId: 'gp-1', lastPromotedAt: new Date(Date.now() - 86_400_000).toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/environment-detail';
|
||||
|
||||
async function snap(page: Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
|
||||
page.on('pageerror', err => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function setupMocks(page: Page) {
|
||||
// Catch-alls first (lowest priority)
|
||||
await page.route('**/api/v2/releases/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RUNS) });
|
||||
});
|
||||
await page.route('**/api/v2/security/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_FINDINGS) });
|
||||
});
|
||||
await page.route('**/api/v2/evidence/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CAPSULES) });
|
||||
});
|
||||
|
||||
// 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: '[]' }));
|
||||
|
||||
// Validate target
|
||||
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
|
||||
const targetId = route.request().url().match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
|
||||
const rpt = MOCK_READINESS.items.find(r => r.targetId === targetId) ?? MOCK_READINESS.items[0];
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(rpt) });
|
||||
});
|
||||
|
||||
// Readiness
|
||||
await page.route('**/api/v1/environments/*/readiness', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_READINESS) });
|
||||
});
|
||||
|
||||
// Topology APIs (registered last = highest priority)
|
||||
await page.route('**/api/v2/topology/promotion-paths**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PROMOTION_PATHS) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/hosts**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_HOSTS) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/agents**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_AGENTS) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/targets**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_TARGETS) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/layout**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ nodes: [], edges: [], metadata: { regionCount: 0, environmentCount: 0, promotionPathCount: 0, canvasWidth: 0, canvasHeight: 0 } }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ENVIRONMENTS) });
|
||||
});
|
||||
}
|
||||
|
||||
const DETAIL_URL = `/environments/environments/${ENV_ID}`;
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Environment Detail Page', () => {
|
||||
|
||||
test('header shows environment name, health badge, back link, and actions', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Back link
|
||||
const back = page.locator('.hdr__back');
|
||||
await expect(back).toBeVisible();
|
||||
await expect(back).toContainText('Environments');
|
||||
|
||||
// Environment name
|
||||
await expect(page.locator('.hdr__title-row h1')).toContainText('EU Production');
|
||||
|
||||
// Health badge
|
||||
const healthBadge = page.locator('.hdr__title-row app-status-badge').first();
|
||||
await expect(healthBadge).toBeVisible();
|
||||
|
||||
// Deploy button in header
|
||||
await expect(page.locator('.hdr__actions a:has-text("Deploy")')).toBeVisible();
|
||||
|
||||
// Refresh button
|
||||
await expect(page.locator('button:has-text("Refresh")')).toBeVisible();
|
||||
|
||||
await snap(page, '01-header');
|
||||
|
||||
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError'));
|
||||
expect(critical).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('overview tab shows metric cards with correct values', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Metric cards should be present (4 in the stat group)
|
||||
const metricCards = page.locator('app-metric-card');
|
||||
expect(await metricCards.count()).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Health circle should be visible
|
||||
const healthCircle = page.locator('.health-circle');
|
||||
await expect(healthCircle).toBeVisible();
|
||||
|
||||
// Quick stats
|
||||
const quickStats = page.locator('.quick-stats');
|
||||
await expect(quickStats).toBeVisible();
|
||||
|
||||
await snap(page, '02-overview');
|
||||
});
|
||||
|
||||
test('overview tab shows blockers when unhealthy targets exist', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should have blockers (1 unhealthy target + 1 blocking finding + 1 failing readiness + 1 degraded agent)
|
||||
const blockerItems = page.locator('.blocker-item');
|
||||
expect(await blockerItems.count()).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Each blocker should have a status badge
|
||||
const badges = page.locator('.blocker-item app-status-badge');
|
||||
expect(await badges.count()).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await snap(page, '03-blockers');
|
||||
});
|
||||
|
||||
test('targets tab shows display names instead of GUIDs', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=targets`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should show host display names, not raw GUIDs
|
||||
const tableText = await page.locator('table').innerText();
|
||||
expect(tableText).toContain('prod-host-alpha');
|
||||
expect(tableText).toContain('agent-eu-01');
|
||||
|
||||
// Should NOT show raw GUID h-1 or a-1
|
||||
expect(tableText).not.toContain('h-1');
|
||||
|
||||
// Health status should use StatusBadge
|
||||
const statusBadges = page.locator('table app-status-badge');
|
||||
expect(await statusBadges.count()).toBe(3); // 3 targets
|
||||
|
||||
await snap(page, '04-targets');
|
||||
});
|
||||
|
||||
test('readiness tab shows gate grid with status badges', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=readiness`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Summary metric cards (Ready/Failing/Pending)
|
||||
const metrics = page.locator('app-metric-card');
|
||||
expect(await metrics.count()).toBe(3);
|
||||
|
||||
// Gate grid table should have 3 target rows
|
||||
const rows = page.locator('table tbody tr');
|
||||
expect(await rows.count()).toBe(3);
|
||||
|
||||
// Validate All button
|
||||
const validateAll = page.locator('button:has-text("Validate All")');
|
||||
await expect(validateAll).toBeVisible();
|
||||
|
||||
// Per-row Validate buttons
|
||||
const validateBtns = page.locator('button:has-text("Validate")');
|
||||
expect(await validateBtns.count()).toBeGreaterThanOrEqual(3); // 3 per-target + 1 Validate All
|
||||
|
||||
await snap(page, '05-readiness');
|
||||
});
|
||||
|
||||
test('readiness tab has validate buttons for each target', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=readiness`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should have per-row Validate buttons (one per target)
|
||||
const validateBtns = page.locator('table tbody .btn--xs');
|
||||
expect(await validateBtns.count()).toBe(3); // 3 targets
|
||||
|
||||
// Validate All button should be visible
|
||||
const validateAll = page.locator('.panel__hdr button:has-text("Validate All")');
|
||||
await expect(validateAll).toBeVisible();
|
||||
|
||||
// Buttons should not be disabled initially
|
||||
const firstBtn = validateBtns.first();
|
||||
await expect(firstBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('runs tab shows formatted dates and status badges', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=deployments`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const tableText = await page.locator('table').innerText();
|
||||
|
||||
// Should show release name
|
||||
expect(tableText).toContain('v2.4.1');
|
||||
expect(tableText).toContain('v2.4.0');
|
||||
|
||||
// Status badges
|
||||
const badges = page.locator('table app-status-badge');
|
||||
expect(await badges.count()).toBe(2);
|
||||
|
||||
// Should have "View" links
|
||||
const viewLinks = page.locator('table a:has-text("View")');
|
||||
expect(await viewLinks.count()).toBe(2);
|
||||
|
||||
// Dates should be relative
|
||||
expect(tableText).toMatch(/ago|just now/);
|
||||
|
||||
await snap(page, '06-runs');
|
||||
});
|
||||
|
||||
test('agents tab shows status badges and relative heartbeat times', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=agents`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const tableText = await page.locator('table').innerText();
|
||||
expect(tableText).toContain('agent-eu-01');
|
||||
expect(tableText).toContain('agent-eu-02');
|
||||
|
||||
// Status badges (active + degraded)
|
||||
const badges = page.locator('table app-status-badge');
|
||||
expect(await badges.count()).toBe(2);
|
||||
|
||||
// Heartbeat should show relative time, not raw ISO
|
||||
// (RelativeTimePipe converts to "X minutes ago" etc.)
|
||||
expect(tableText).toMatch(/ago|just now|seconds|minutes|hours|days/);
|
||||
|
||||
await snap(page, '07-agents');
|
||||
});
|
||||
|
||||
test('security tab shows CVSS, reachability, and links', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=security`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const tableText = await page.locator('table').innerText();
|
||||
|
||||
// CVE IDs
|
||||
expect(tableText).toContain('CVE-2024-1234');
|
||||
expect(tableText).toContain('CVE-2024-5678');
|
||||
|
||||
// CVSS scores
|
||||
expect(tableText).toContain('9.8');
|
||||
expect(tableText).toContain('5.3');
|
||||
|
||||
// Severity badges
|
||||
const badges = page.locator('table app-status-badge');
|
||||
expect(await badges.count()).toBeGreaterThanOrEqual(4); // severity + reachable per row
|
||||
|
||||
// View links
|
||||
const viewLinks = page.locator('table a:has-text("View")');
|
||||
expect(await viewLinks.count()).toBe(2);
|
||||
|
||||
await snap(page, '08-security');
|
||||
});
|
||||
|
||||
test('evidence tab shows card grid with signed badges', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=evidence`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Evidence cards
|
||||
const cards = page.locator('.ev-card');
|
||||
expect(await cards.count()).toBe(2);
|
||||
|
||||
// Content type badges
|
||||
const typeBadges = page.locator('.ev-type');
|
||||
expect(await typeBadges.count()).toBe(2);
|
||||
|
||||
// Signature badges
|
||||
const sigBadges = page.locator('.ev-badges app-status-badge');
|
||||
expect(await sigBadges.count()).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await snap(page, '09-evidence');
|
||||
});
|
||||
|
||||
test('drift tab detects version divergence across targets', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=drift`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// t-1 and t-2 have rv-1, t-3 has rv-2 — drift should be detected
|
||||
const driftAlert = page.locator('.drift-alert');
|
||||
await expect(driftAlert).toBeVisible();
|
||||
|
||||
// Should mention drifted target count
|
||||
const alertText = await driftAlert.innerText();
|
||||
expect(alertText).toContain('1 target');
|
||||
|
||||
await snap(page, '10-drift');
|
||||
});
|
||||
|
||||
test('data quality tab shows metric cards with severity coloring', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=data-quality`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const metricCards = page.locator('app-metric-card');
|
||||
expect(await metricCards.count()).toBe(4);
|
||||
|
||||
await snap(page, '11-data-quality');
|
||||
});
|
||||
|
||||
test('tab bar shows status dots and badge counts', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Tab bar should exist with 9 tabs
|
||||
const tabs = page.locator('stella-page-tabs button[role="tab"]');
|
||||
expect(await tabs.count()).toBe(9);
|
||||
|
||||
await snap(page, '12-tab-bar');
|
||||
});
|
||||
|
||||
test('no Angular errors across all tabs', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await setupMocks(page);
|
||||
|
||||
const tabs: string[] = ['overview', 'targets', 'readiness', 'deployments', 'agents', 'security', 'evidence', 'drift', 'data-quality'];
|
||||
|
||||
for (const tab of tabs) {
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=${tab}`);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
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, '13-no-errors');
|
||||
});
|
||||
|
||||
test('promotion path context shown in header', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Promotion line should show upstream env
|
||||
const promoLine = page.locator('.hdr__promo');
|
||||
if (await promoLine.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const text = await promoLine.innerText();
|
||||
// Should mention EU Staging (upstream) and EU Production (this env)
|
||||
expect(text).toContain('EU Staging');
|
||||
expect(text).toContain('EU Production');
|
||||
}
|
||||
|
||||
await snap(page, '14-promotion-context');
|
||||
});
|
||||
});
|
||||
731
src/Web/StellaOps.Web/e2e/environments-command.e2e.spec.ts
Normal file
731
src/Web/StellaOps.Web/e2e/environments-command.e2e.spec.ts
Normal file
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* Environments Command Center — E2E Tests
|
||||
*
|
||||
* Verifies the unified environments page that combines readiness gate status
|
||||
* with topology visualization. Tests command view (cards + gates),
|
||||
* topology view (SVG graph), filtering, actions, and redirects.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait, assertPageHasContent } from './helpers/nav.helper';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
// ─── Mock data ──────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_ENVS = {
|
||||
items: [
|
||||
{ environmentId: 'eu-prod', displayName: 'EU Production', regionId: 'eu-west', environmentType: 'production' },
|
||||
{ environmentId: 'eu-stage', displayName: 'EU Staging', regionId: 'eu-west', environmentType: 'staging' },
|
||||
{ environmentId: 'us-prod', displayName: 'US Production', regionId: 'us-east', environmentType: 'production' },
|
||||
{ environmentId: 'us-uat', displayName: 'US UAT', regionId: 'us-east', environmentType: 'uat' },
|
||||
{ environmentId: 'prod-us-west', displayName: 'US West Production', regionId: 'us-west', environmentType: 'production' },
|
||||
{ environmentId: 'apac-prod', displayName: 'APAC Production', regionId: 'apac', environmentType: 'production' },
|
||||
],
|
||||
};
|
||||
|
||||
function gate(name: string, status: string, msg: string) {
|
||||
return { gateName: name, status, message: msg, checkedAt: new Date().toISOString(), durationMs: 120 };
|
||||
}
|
||||
|
||||
function allPassGates() {
|
||||
return [
|
||||
gate('agent_bound', 'pass', 'Agent heartbeat OK'),
|
||||
gate('docker_version_ok', 'pass', 'Docker 24.0.9'),
|
||||
gate('docker_ping_ok', 'pass', 'Daemon reachable'),
|
||||
gate('registry_pull_ok', 'pass', 'Pull test OK'),
|
||||
gate('vault_reachable', 'pass', 'Vault unsealed'),
|
||||
gate('consul_reachable', 'pass', 'Consul leader elected'),
|
||||
gate('connectivity_ok', 'pass', 'All required gates pass'),
|
||||
];
|
||||
}
|
||||
|
||||
/** EU Production: 3 targets, ALL READY */
|
||||
const READINESS_EU_PROD = {
|
||||
items: [
|
||||
{ targetId: 't-eup-1', targetName: 'eu-prod-app-01', environmentId: 'eu-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-eup-2', targetName: 'eu-prod-app-02', environmentId: 'eu-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-eup-3', targetName: 'eu-prod-worker-01', environmentId: 'eu-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** EU Staging: 2 targets, 1 FAILING (registry) */
|
||||
const READINESS_EU_STAGE = {
|
||||
items: [
|
||||
{ targetId: 't-eus-1', targetName: 'eu-stage-app-01', environmentId: 'eu-stage', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-eus-2', targetName: 'eu-stage-app-02', environmentId: 'eu-stage', isReady: false, gates: [
|
||||
gate('agent_bound', 'pass', 'Agent heartbeat OK'),
|
||||
gate('docker_version_ok', 'pass', 'Docker 24.0.9'),
|
||||
gate('docker_ping_ok', 'pass', 'Daemon reachable'),
|
||||
gate('registry_pull_ok', 'fail', 'Connection refused: registry.internal:5000'),
|
||||
gate('vault_reachable', 'pass', 'Vault unsealed'),
|
||||
gate('consul_reachable', 'pass', 'Consul leader elected'),
|
||||
gate('connectivity_ok', 'fail', 'Required gate registry_pull_ok failed'),
|
||||
], evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** US Production: 2 targets, ALL READY */
|
||||
const READINESS_US_PROD = {
|
||||
items: [
|
||||
{ targetId: 't-usp-1', targetName: 'us-prod-app-01', environmentId: 'us-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-usp-2', targetName: 'us-prod-worker-01', environmentId: 'us-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** US UAT: 2 targets, 1 PENDING (agent not bound) */
|
||||
const READINESS_US_UAT = {
|
||||
items: [
|
||||
{ targetId: 't-usu-1', targetName: 'us-uat-app-01', environmentId: 'us-uat', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-usu-2', targetName: 'us-uat-worker-01', environmentId: 'us-uat', isReady: false, gates: [
|
||||
gate('agent_bound', 'pending', 'Awaiting agent registration'),
|
||||
gate('docker_version_ok', 'pending', 'Blocked by agent_bound'),
|
||||
gate('docker_ping_ok', 'pending', 'Blocked by agent_bound'),
|
||||
gate('registry_pull_ok', 'pending', 'Blocked by agent_bound'),
|
||||
gate('vault_reachable', 'pending', 'Blocked by agent_bound'),
|
||||
gate('consul_reachable', 'pending', 'Blocked by agent_bound'),
|
||||
gate('connectivity_ok', 'fail', 'Required gate agent_bound is pending'),
|
||||
], evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** US West Production: 1 target, ALL READY */
|
||||
const READINESS_US_WEST = {
|
||||
items: [
|
||||
{ targetId: 't-uwp-1', targetName: 'usw-prod-app-01', environmentId: 'prod-us-west', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** APAC Production: 2 targets, 1 FAILING (consul partitioned) */
|
||||
const READINESS_APAC = {
|
||||
items: [
|
||||
{ targetId: 't-ap-1', targetName: 'apac-prod-app-01', environmentId: 'apac-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-ap-2', targetName: 'apac-prod-worker-01', environmentId: 'apac-prod', isReady: false, gates: [
|
||||
gate('agent_bound', 'pass', 'Agent heartbeat OK'),
|
||||
gate('docker_version_ok', 'pass', 'Docker 25.0.3'),
|
||||
gate('docker_ping_ok', 'pass', 'Daemon reachable'),
|
||||
gate('registry_pull_ok', 'pass', 'Pull test OK'),
|
||||
gate('vault_reachable', 'pass', 'Vault unsealed'),
|
||||
gate('consul_reachable', 'fail', 'No Consul leader — cluster partitioned'),
|
||||
gate('connectivity_ok', 'fail', 'Required gate consul_reachable failed'),
|
||||
], evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const READINESS_BY_ENV: Record<string, { items: any[] }> = {
|
||||
'eu-prod': READINESS_EU_PROD,
|
||||
'eu-stage': READINESS_EU_STAGE,
|
||||
'us-prod': READINESS_US_PROD,
|
||||
'us-uat': READINESS_US_UAT,
|
||||
'prod-us-west': READINESS_US_WEST,
|
||||
'apac-prod': READINESS_APAC,
|
||||
};
|
||||
|
||||
const MOCK_TOPOLOGY_LAYOUT = {
|
||||
nodes: [
|
||||
{ id: 'region-eu-west', label: 'EU West', kind: 'region', parentNodeId: null, x: 0, y: 0, width: 400, height: 150, hostCount: 0, targetCount: 5, isFrozen: false, promotionPathCount: 1, deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 0 },
|
||||
{ id: 'env-eu-prod', label: 'EU Production', kind: 'environment', parentNodeId: 'region-eu-west', x: 220, y: 50, width: 160, height: 50, environmentId: 'eu-prod', regionId: 'eu-west', environmentType: 'production', healthStatus: 'healthy', hostCount: 3, targetCount: 3, isFrozen: false, promotionPathCount: 1, deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 5 },
|
||||
{ id: 'env-eu-stage', label: 'EU Staging', kind: 'environment', parentNodeId: 'region-eu-west', x: 20, y: 50, width: 160, height: 50, environmentId: 'eu-stage', regionId: 'eu-west', environmentType: 'staging', healthStatus: 'degraded', hostCount: 2, targetCount: 2, isFrozen: false, promotionPathCount: 1, deployingCount: 0, pendingCount: 0, failedCount: 1, totalDeployments: 3 },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'path-eu-stage-to-prod', sourceNodeId: 'env-eu-stage', targetNodeId: 'env-eu-prod', kind: 'promotion', label: 'auto-promote', pathMode: 'auto', status: 'active', requiredApprovals: 0, sections: [{ startPoint: { x: 180, y: 75 }, endPoint: { x: 220, y: 75 }, bendPoints: [] }] },
|
||||
],
|
||||
metadata: { regionCount: 1, environmentCount: 2, promotionPathCount: 1, canvasWidth: 440, canvasHeight: 170 },
|
||||
};
|
||||
|
||||
const MOCK_CONTEXT_REGIONS = [
|
||||
{ regionId: 'eu-west', displayName: 'EU West', enabled: true },
|
||||
{ regionId: 'us-east', displayName: 'US East', enabled: true },
|
||||
{ regionId: 'us-west', displayName: 'US West', enabled: true },
|
||||
{ regionId: 'apac', displayName: 'Asia-Pacific', enabled: true },
|
||||
];
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/environments-command';
|
||||
|
||||
async function snap(page: Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); });
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function setupMockRoutes(page: Page) {
|
||||
// IMPORTANT: In Playwright, the LAST registered route is checked FIRST.
|
||||
// Register catch-alls BEFORE specific mocks so specific mocks take priority.
|
||||
|
||||
// Catch-all for release/topology APIs (registered first = lowest priority)
|
||||
await page.route('**/api/v2/releases/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/targets**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/agents**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/hosts**', (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: JSON.stringify(MOCK_CONTEXT_REGIONS) });
|
||||
});
|
||||
await page.route('**/api/v2/context/preferences', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ regions: [], environments: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/context/environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ENVS.items) });
|
||||
});
|
||||
|
||||
// Validate target (returns updated report)
|
||||
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const targetId = url.match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
|
||||
for (const env of Object.values(READINESS_BY_ENV)) {
|
||||
const target = env.items.find((t: any) => t.targetId === targetId);
|
||||
if (target) {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(target) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
route.fulfill({ status: 404, body: 'target not found' });
|
||||
});
|
||||
|
||||
// Topology layout (registered after catch-alls = higher priority)
|
||||
await page.route('**/api/v2/topology/layout**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_TOPOLOGY_LAYOUT) });
|
||||
});
|
||||
|
||||
// Per-environment readiness (high priority — registered late)
|
||||
await page.route('**/api/v1/environments/*/readiness', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const envId = url.match(/environments\/([^/]+)\/readiness/)?.[1] ?? '';
|
||||
const data = READINESS_BY_ENV[envId] ?? { items: [] };
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
});
|
||||
|
||||
// Environments list (highest priority — registered last)
|
||||
await page.route('**/api/v2/topology/environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ENVS) });
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Environments Command Center', () => {
|
||||
|
||||
// ── Scenario 1: Page loads and renders all environments ──
|
||||
|
||||
test('renders command view with all 6 environments from API', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Shell header
|
||||
await expect(page.locator('h1')).toContainText('Environments');
|
||||
|
||||
// View toggle present with Command active
|
||||
const commandBtn = page.locator('button:has-text("Command")');
|
||||
await expect(commandBtn).toBeVisible();
|
||||
|
||||
// Summary strip shows correct counts
|
||||
// Total targets: eu-prod:3 + eu-stage:2 + us-prod:2 + us-uat:2 + us-west:1 + apac:2 = 12
|
||||
const summaryCards = page.locator('.sc__v');
|
||||
const values = await summaryCards.allTextContents();
|
||||
// [0]=envs, [1]=targets, [2]=ready, [3]=not-ready, [4]=failed-gates
|
||||
expect(parseInt(values[0])).toBe(6);
|
||||
expect(parseInt(values[1])).toBe(12);
|
||||
// Ready: eu-prod:3 + eu-stage:1 + us-prod:2 + us-uat:1 + us-west:1 + apac:1 = 9
|
||||
expect(parseInt(values[2])).toBe(9);
|
||||
// Not ready: eu-stage:1 + us-uat:1 + apac:1 = 3
|
||||
expect(parseInt(values[3])).toBe(3);
|
||||
|
||||
// Environment cards present
|
||||
const envCards = page.locator('.env-card');
|
||||
await expect(envCards).toHaveCount(6);
|
||||
|
||||
await snap(page, '01-command-view-loaded');
|
||||
|
||||
// No critical Angular errors
|
||||
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError'));
|
||||
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Scenario 2: Not-ready environments appear first ──
|
||||
|
||||
test('sorts not-ready environments before ready ones', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const firstCard = page.locator('.env-card').first();
|
||||
// First card should be a not-ready env (has --bad class)
|
||||
await expect(firstCard).toHaveClass(/env-card--bad/);
|
||||
|
||||
// Last cards should be fully-ready (--ok class)
|
||||
const cards = page.locator('.env-card');
|
||||
const count = await cards.count();
|
||||
const lastCard = cards.nth(count - 1);
|
||||
await expect(lastCard).toHaveClass(/env-card--ok/);
|
||||
});
|
||||
|
||||
// ── Scenario 3: Gate grid shows correct status icons ──
|
||||
|
||||
test('gate grid shows pass/fail/pending icons correctly', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Find the EU Staging card (has a failing target)
|
||||
const euStageCard = page.locator('.env-card', { hasText: 'EU Staging' });
|
||||
await expect(euStageCard).toBeVisible();
|
||||
|
||||
// Should show 1/2 ready
|
||||
await expect(euStageCard.locator('.env-card__score')).toContainText('1/2 ready');
|
||||
|
||||
// Should contain fail icons (✗)
|
||||
const failCells = euStageCard.locator('.gc--fail');
|
||||
expect(await failCells.count()).toBeGreaterThanOrEqual(2); // registry_pull_ok + connectivity_ok
|
||||
|
||||
// Should contain pass icons (✓)
|
||||
const passCells = euStageCard.locator('.gc--pass');
|
||||
expect(await passCells.count()).toBeGreaterThanOrEqual(5); // most gates pass on the good target
|
||||
|
||||
await snap(page, '02-gate-status-icons');
|
||||
});
|
||||
|
||||
// ── Scenario 4: Blocker section appears for failing environments ──
|
||||
|
||||
test('shows blockers with remediation hints for failing environments', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// APAC card has consul failure
|
||||
const apacCard = page.locator('.env-card', { hasText: 'APAC Production' });
|
||||
await expect(apacCard).toBeVisible();
|
||||
|
||||
// Blockers section present
|
||||
const blockers = apacCard.locator('.blockers');
|
||||
await expect(blockers).toBeVisible();
|
||||
|
||||
// Shows the failed gate name
|
||||
await expect(blockers).toContainText('Consul Reachable');
|
||||
|
||||
// Shows remediation hint
|
||||
await expect(blockers).toContainText('Consul cluster health');
|
||||
|
||||
await snap(page, '03-blockers-remediation');
|
||||
});
|
||||
|
||||
// ── Scenario 5: Deploy button only on all-ready environments ──
|
||||
|
||||
test('Deploy button appears only when all targets are ready', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// EU Production is fully ready — should have Deploy button
|
||||
const euProdCard = page.locator('.env-card', { hasText: 'EU Production' });
|
||||
const deployBtn = euProdCard.locator('a:has-text("Deploy")');
|
||||
await expect(deployBtn).toBeVisible();
|
||||
|
||||
// EU Staging has a failure — no Deploy button
|
||||
const euStageCard = page.locator('.env-card', { hasText: 'EU Staging' });
|
||||
const noDeployBtn = euStageCard.locator('a:has-text("Deploy")');
|
||||
await expect(noDeployBtn).toHaveCount(0);
|
||||
|
||||
// US UAT has pending — no Deploy button
|
||||
const usUatCard = page.locator('.env-card', { hasText: 'US UAT' });
|
||||
const noDeployBtn2 = usUatCard.locator('a:has-text("Deploy")');
|
||||
await expect(noDeployBtn2).toHaveCount(0);
|
||||
|
||||
await snap(page, '04-deploy-button-gating');
|
||||
});
|
||||
|
||||
// ── Scenario 6: Region filtering via global context ──
|
||||
|
||||
test('region filtering is handled by global context bar, not local chips', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// The toolbar should NOT have region chip buttons (those are in the global context bar)
|
||||
const toolbar = page.locator('.toolbar');
|
||||
await expect(toolbar).toBeVisible();
|
||||
|
||||
// Status chips should be in the toolbar
|
||||
const statusChips = toolbar.locator('.status-chips');
|
||||
await expect(statusChips).toBeVisible();
|
||||
await expect(statusChips.locator('button', { hasText: 'Ready' }).first()).toBeVisible();
|
||||
await expect(statusChips.locator('button', { hasText: 'Not Ready' })).toBeVisible();
|
||||
|
||||
// View toggle should be in the same row as status
|
||||
await expect(toolbar.locator('button:has-text("Command")')).toBeVisible();
|
||||
await expect(toolbar.locator('button:has-text("Topology")')).toBeVisible();
|
||||
|
||||
await snap(page, '05-toolbar-layout');
|
||||
});
|
||||
|
||||
// ── Scenario 7: Status filter works ──
|
||||
|
||||
test('status filter shows only ready or not-ready environments', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click "Not Ready" status chip in the toolbar
|
||||
const notReadyChip = page.locator('.status-chips button.chip--err');
|
||||
await notReadyChip.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should only show targets that are not ready (3 targets across 3 envs)
|
||||
const targets = page.locator('.gg__row');
|
||||
const targetCount = await targets.count();
|
||||
expect(targetCount).toBe(3); // 1 from eu-stage + 1 from us-uat + 1 from apac
|
||||
|
||||
// All rows should have --bad class
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
await expect(targets.nth(i)).toHaveClass(/gg__row--bad/);
|
||||
}
|
||||
|
||||
await snap(page, '06-status-filter-not-ready');
|
||||
|
||||
// Switch to "Ready" filter
|
||||
const readyChip = page.locator('.status-chips button.chip--ok');
|
||||
await readyChip.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show 9 ready targets
|
||||
const readyTargets = page.locator('.gg__row');
|
||||
expect(await readyTargets.count()).toBe(9);
|
||||
|
||||
await snap(page, '07-status-filter-ready');
|
||||
});
|
||||
|
||||
// ── Scenario 8: Toggle to topology view ──
|
||||
|
||||
test('topology toggle shows SVG graph view', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click Topology toggle
|
||||
const topoBtn = page.locator('button:has-text("Topology")');
|
||||
await topoBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// SVG graph should be visible
|
||||
const svgCanvas = page.locator('svg.topology-canvas');
|
||||
await expect(svgCanvas).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Environment cards should NOT be visible
|
||||
await expect(page.locator('.env-card')).toHaveCount(0);
|
||||
|
||||
// Summary strip should still be visible
|
||||
await expect(page.locator('.summary')).toBeVisible();
|
||||
|
||||
await snap(page, '08-topology-view');
|
||||
});
|
||||
|
||||
// ── Scenario 9: Topology node click opens drawer ──
|
||||
|
||||
test('clicking a topology environment node opens detail drawer', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview?view=topology');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Click an environment node in the SVG
|
||||
const envNode = page.locator('.env-group').first();
|
||||
if (await envNode.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await envNode.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Drawer should appear
|
||||
const drawer = page.locator('.topo-drawer');
|
||||
if (await drawer.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
// Should show readiness summary
|
||||
await expect(drawer.locator('.td__readiness')).toBeVisible();
|
||||
|
||||
// "Open Detail" link should exist
|
||||
await expect(drawer.locator('a:has-text("Open Detail")')).toBeVisible();
|
||||
|
||||
// "Switch to Command" button should exist
|
||||
await expect(drawer.locator('button:has-text("Switch to Command")')).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
await snap(page, '09-topology-drawer');
|
||||
});
|
||||
|
||||
// ── Scenario 10: /releases/readiness redirects to /environments/overview ──
|
||||
|
||||
test('legacy /releases/readiness redirects to /environments/overview', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await page.goto('/releases/readiness', { waitUntil: 'networkidle', timeout: 15_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// URL should have changed to /environments/overview
|
||||
expect(page.url()).toContain('/environments/overview');
|
||||
|
||||
await snap(page, '10-readiness-redirect');
|
||||
});
|
||||
|
||||
// ── Scenario 11: Validate All triggers API calls ──
|
||||
|
||||
test('Validate All button sends validation requests for all visible targets', async ({ authenticatedPage: page }) => {
|
||||
const validateCalls: string[] = [];
|
||||
await setupMockRoutes(page);
|
||||
|
||||
// Track validate calls
|
||||
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const targetId = url.match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
|
||||
validateCalls.push(targetId);
|
||||
// Return mock result
|
||||
for (const env of Object.values(READINESS_BY_ENV)) {
|
||||
const target = env.items.find((t: any) => t.targetId === targetId);
|
||||
if (target) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(target) }); return; }
|
||||
}
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ targetId, isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString(), environmentId: 'unknown' }) });
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click Validate All
|
||||
const validateAllBtn = page.locator('button:has-text("Validate All")');
|
||||
await validateAllBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should have called validate for all 12 targets
|
||||
expect(validateCalls.length).toBe(12);
|
||||
|
||||
await snap(page, '11-validate-all');
|
||||
});
|
||||
|
||||
// ── Scenario 12: Per-environment Re-check ──
|
||||
|
||||
test('Re-check button validates only targets in that environment', async ({ authenticatedPage: page }) => {
|
||||
const validateCalls: string[] = [];
|
||||
await setupMockRoutes(page);
|
||||
|
||||
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const targetId = url.match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
|
||||
validateCalls.push(targetId);
|
||||
for (const env of Object.values(READINESS_BY_ENV)) {
|
||||
const target = env.items.find((t: any) => t.targetId === targetId);
|
||||
if (target) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(target) }); return; }
|
||||
}
|
||||
route.fulfill({ status: 404 });
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Find the EU Staging card and click Re-check
|
||||
const euStageCard = page.locator('.env-card', { hasText: 'EU Staging' });
|
||||
const recheckBtn = euStageCard.locator('button:has-text("Re-check")');
|
||||
await recheckBtn.click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Should have called validate for only 2 targets (eu-stage's targets)
|
||||
expect(validateCalls.length).toBe(2);
|
||||
expect(validateCalls).toContain('t-eus-1');
|
||||
expect(validateCalls).toContain('t-eus-2');
|
||||
});
|
||||
|
||||
// ── Scenario 13: All environments load when no context filter applied ──
|
||||
|
||||
test('shows all environments when global context has no region filter', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// All 6 environments should be visible
|
||||
const cards = page.locator('.env-card');
|
||||
expect(await cards.count()).toBe(6);
|
||||
|
||||
await snap(page, '12-all-environments');
|
||||
});
|
||||
|
||||
// ── Scenario 14: Clear status filter resets to All ──
|
||||
|
||||
test('Clear button resets status filter back to All', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Apply "Not Ready" filter
|
||||
await page.locator('.status-chips button.chip--err').click();
|
||||
await page.waitForTimeout(500);
|
||||
expect(await page.locator('.gg__row').count()).toBe(3); // only 3 not-ready targets
|
||||
|
||||
// Click Clear
|
||||
const clearBtn = page.locator('button:has-text("Clear")');
|
||||
await clearBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// All 6 environments visible again
|
||||
expect(await page.locator('.env-card').count()).toBe(6);
|
||||
});
|
||||
|
||||
// ── Scenario 15: View Detail link navigates to environment detail ──
|
||||
|
||||
test('View Detail link navigates to environment detail page', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
|
||||
// Mock the environment detail page APIs
|
||||
await page.route('**/api/v2/topology/targets**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/agents**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/security/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/evidence/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click "View Detail" on EU Production card
|
||||
const euProdCard = page.locator('.env-card', { hasText: 'EU Production' });
|
||||
const detailLink = euProdCard.locator('a:has-text("View Detail")');
|
||||
await detailLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should navigate to /environments/environments/eu-prod
|
||||
expect(page.url()).toContain('/environments/environments/eu-prod');
|
||||
|
||||
await snap(page, '13-view-detail-navigation');
|
||||
});
|
||||
|
||||
// ── Scenario 16: No Angular errors across all views ──
|
||||
|
||||
test('no critical Angular errors navigating between command and topology views', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await setupMockRoutes(page);
|
||||
|
||||
// Load command view
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Switch to topology
|
||||
await page.locator('button:has-text("Topology")').click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Switch back to command
|
||||
await page.locator('button:has-text("Command")').click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Apply status filter
|
||||
await page.locator('.status-chips button.chip--err').click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Switch to topology again
|
||||
await page.locator('button:has-text("Topology")').click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
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, '14-no-errors');
|
||||
});
|
||||
|
||||
// ── Scenario 17: Empty state when no environments match filter ──
|
||||
|
||||
test('status filter narrows down visible targets correctly', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Filter for "Not Ready" — should show 3 failing targets across 3 envs
|
||||
await page.locator('.status-chips button.chip--err').click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const rows = page.locator('.gg__row');
|
||||
expect(await rows.count()).toBe(3);
|
||||
|
||||
// All rows should have the --bad class
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(rows.nth(i)).toHaveClass(/gg__row--bad/);
|
||||
}
|
||||
|
||||
await snap(page, '15-filtered-not-ready-targets');
|
||||
});
|
||||
|
||||
// ── Scenario 18: Pending gates are shown distinctly from failures ──
|
||||
|
||||
test('pending gates display with warning color distinct from failures', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Find US UAT card (has pending agent)
|
||||
const usUatCard = page.locator('.env-card', { hasText: 'US UAT' });
|
||||
await expect(usUatCard).toBeVisible();
|
||||
|
||||
// Should have pending indicator cells (●)
|
||||
const pendingCells = usUatCard.locator('.gc--pend');
|
||||
expect(await pendingCells.count()).toBeGreaterThanOrEqual(5); // 5 pending gates on the worker target
|
||||
|
||||
// Should have both fail AND pending blockers
|
||||
const blockers = usUatCard.locator('.blockers');
|
||||
await expect(blockers).toBeVisible();
|
||||
await expect(blockers).toContainText('Agent Bound');
|
||||
await expect(blockers).toContainText('Awaiting agent registration');
|
||||
|
||||
await snap(page, '16-pending-vs-fail');
|
||||
});
|
||||
|
||||
// ── Scenario 19: Deploy button links to correct route ──
|
||||
|
||||
test('Deploy button links to create deployment with environment query param', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const euProdCard = page.locator('.env-card', { hasText: 'EU Production' });
|
||||
const deployLink = euProdCard.locator('a:has-text("Deploy")');
|
||||
|
||||
const href = await deployLink.getAttribute('href');
|
||||
expect(href).toContain('/releases/deployments/new');
|
||||
expect(href).toContain('environment=eu-prod');
|
||||
});
|
||||
|
||||
// ── Scenario 20: Multiple diverse failure types across environments ──
|
||||
|
||||
test('different failure types shown correctly across environments', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// EU Staging: registry failure
|
||||
const euStageBlockers = page.locator('.env-card', { hasText: 'EU Staging' }).locator('.blockers');
|
||||
await expect(euStageBlockers).toContainText('Registry Pull');
|
||||
|
||||
// US UAT: agent pending
|
||||
const usUatBlockers = page.locator('.env-card', { hasText: 'US UAT' }).locator('.blockers');
|
||||
await expect(usUatBlockers).toContainText('Agent Bound');
|
||||
|
||||
// APAC: consul failure
|
||||
const apacBlockers = page.locator('.env-card', { hasText: 'APAC Production' }).locator('.blockers');
|
||||
await expect(apacBlockers).toContainText('Consul Reachable');
|
||||
|
||||
await snap(page, '17-diverse-failures');
|
||||
});
|
||||
});
|
||||
75
src/Web/StellaOps.Web/e2e/fixtures/live-auth.fixture.ts
Normal file
75
src/Web/StellaOps.Web/e2e/fixtures/live-auth.fixture.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Live auth fixture for integration tests against the real Stella Ops stack.
|
||||
*
|
||||
* Unlike the mocked auth.fixture.ts, this performs a real OIDC login against
|
||||
* the live Authority service and extracts a Bearer token for API calls.
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local';
|
||||
const ADMIN_USER = process.env['STELLAOPS_ADMIN_USER'] || 'admin';
|
||||
const ADMIN_PASS = process.env['STELLAOPS_ADMIN_PASS'] || 'Admin@Stella2026!';
|
||||
|
||||
export const test = base.extend<{
|
||||
liveAuthPage: Page;
|
||||
apiToken: string;
|
||||
apiRequest: APIRequestContext;
|
||||
}>({
|
||||
liveAuthPage: async ({ page }, use) => {
|
||||
// Navigate to the app — will redirect to login
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
|
||||
// If we land on /welcome, click Sign In
|
||||
if (page.url().includes('/welcome')) {
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await page.waitForURL('**/connect/authorize**', { timeout: 10_000 });
|
||||
}
|
||||
|
||||
// Fill login form if present
|
||||
const usernameField = page.getByRole('textbox', { name: /username/i });
|
||||
if (await usernameField.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await usernameField.fill(ADMIN_USER);
|
||||
await page.getByRole('textbox', { name: /password/i }).fill(ADMIN_PASS);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await page.waitForURL(`${BASE_URL}/**`, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
// Wait for app to load
|
||||
await page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
|
||||
await use(page);
|
||||
},
|
||||
|
||||
apiToken: async ({ liveAuthPage: page }, use) => {
|
||||
// Extract the Bearer token from session storage
|
||||
const token = await page.evaluate(() => {
|
||||
const session = sessionStorage.getItem('stellaops.auth.session.full');
|
||||
if (!session) return null;
|
||||
const parsed = JSON.parse(session);
|
||||
return parsed?.tokens?.accessToken ?? null;
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Failed to extract auth token from session storage');
|
||||
}
|
||||
|
||||
await use(token);
|
||||
},
|
||||
|
||||
apiRequest: async ({ playwright, apiToken }, use) => {
|
||||
const ctx = await playwright.request.newContext({
|
||||
baseURL: BASE_URL,
|
||||
extraHTTPHeaders: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
await use(ctx);
|
||||
await ctx.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
701
src/Web/StellaOps.Web/e2e/navigation-reorganization.e2e.spec.ts
Normal file
701
src/Web/StellaOps.Web/e2e/navigation-reorganization.e2e.spec.ts
Normal file
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* Navigation Reorganization E2E Tests
|
||||
* Black-box QA verification of the 6-group sidebar structure,
|
||||
* tab rationalization, quick actions cleanup, and removed-item accessibility.
|
||||
*
|
||||
* Sprint: UI Navigation & UX Reorganization
|
||||
*/
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/nav-reorg';
|
||||
|
||||
async function snap(page: Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectErrors(page: Page) {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function go(page: Page, path: string) {
|
||||
await page.goto(path, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
function criticalOnly(errors: string[]) {
|
||||
return errors.filter(e =>
|
||||
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. SIDEBAR GROUP STRUCTURE
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Sidebar 6-group structure', () => {
|
||||
test('renders exactly 6 nav groups with correct labels', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
// The sidebar renders group headers as text. Verify each group label exists.
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const sidebarText = await sidebar.innerText();
|
||||
|
||||
// 6-group labels (Home has no label, so 5 visible labels)
|
||||
expect(sidebarText).toContain('Release Control');
|
||||
expect(sidebarText).toContain('Security');
|
||||
expect(sidebarText).toContain('Evidence');
|
||||
expect(sidebarText).toContain('Operations');
|
||||
expect(sidebarText).toContain('Settings');
|
||||
|
||||
// Dissolved groups must NOT appear
|
||||
expect(sidebarText).not.toContain('Policy');
|
||||
expect(sidebarText).not.toContain('Audit & Evidence');
|
||||
|
||||
await snap(page, '01-sidebar-groups');
|
||||
});
|
||||
|
||||
test('Dashboard is visible in Home group', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const dashLink = page.locator('text=Dashboard').first();
|
||||
expect(await dashLink.isVisible({ timeout: 3000 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. RELEASE CONTROL GROUP
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Release Control group', () => {
|
||||
test('contains Deployments, Releases, Environments, Readiness', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Deployments');
|
||||
expect(text).toContain('Releases');
|
||||
expect(text).toContain('Environments');
|
||||
expect(text).toContain('Readiness');
|
||||
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Readiness navigates to /releases/readiness', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
const link = page.locator('text=Readiness').first();
|
||||
if (await link.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await link.click();
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain('/releases/readiness');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length).toBeGreaterThan(10);
|
||||
await snap(page, '02-readiness');
|
||||
}
|
||||
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. SECURITY GROUP (absorbed Policy items)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Security group', () => {
|
||||
test('contains Vulnerabilities, Security Posture, Scan Image', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Vulnerabilities');
|
||||
expect(text).toContain('Security Posture');
|
||||
expect(text).toContain('Scan Image');
|
||||
});
|
||||
|
||||
test('contains absorbed Policy items: VEX & Exceptions, Risk & Governance', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('VEX & Exceptions');
|
||||
expect(text).toContain('Risk & Governance');
|
||||
});
|
||||
|
||||
test('Security Posture has Findings Explorer child', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The Findings Explorer should be visible as a child nav item
|
||||
const findingsExplorer = page.locator('text=Findings Explorer').first();
|
||||
if (await findingsExplorer.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await findingsExplorer.click();
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain('/security/findings');
|
||||
await snap(page, '03-findings-explorer');
|
||||
}
|
||||
});
|
||||
|
||||
test('Security Posture children: Supply-Chain, Reachability, Unknowns', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Supply-Chain Data');
|
||||
expect(text).toContain('Reachability');
|
||||
expect(text).toContain('Unknowns');
|
||||
});
|
||||
|
||||
test('Risk & Governance children: Simulation, Policy Audit', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/governance');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Simulation');
|
||||
expect(text).toContain('Policy Audit');
|
||||
});
|
||||
|
||||
test('VEX & Exceptions navigates to /ops/policy/vex', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
const link = page.locator('text=VEX & Exceptions').first();
|
||||
if (await link.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await link.click();
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain('/ops/policy/vex');
|
||||
await snap(page, '04-vex-exceptions');
|
||||
}
|
||||
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. EVIDENCE GROUP (trimmed)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Evidence group (trimmed)', () => {
|
||||
test('contains exactly 4 items: Overview, Capsules, Audit Log, Export Center', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/overview');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Evidence Overview');
|
||||
expect(text).toContain('Decision Capsules');
|
||||
expect(text).toContain('Audit Log');
|
||||
expect(text).toContain('Export Center');
|
||||
});
|
||||
|
||||
test('removed items NOT in sidebar: Replay, Bundles, Trust', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/overview');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
// These should no longer be sidebar items
|
||||
// Note: "Replay" might appear in page content, so check within sidebar only
|
||||
const evidenceGroup = text.split('Operations')[0]; // text before Operations group
|
||||
expect(evidenceGroup).not.toContain('Replay & Verify');
|
||||
expect(evidenceGroup).not.toContain('Bundles');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. OPERATIONS GROUP (trimmed, absorbed Packs)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Operations group', () => {
|
||||
test('contains Policy Packs (absorbed from Policy group)', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Policy Packs');
|
||||
expect(text).toContain('Operations Hub');
|
||||
expect(text).toContain('Scheduled Jobs');
|
||||
expect(text).toContain('Diagnostics');
|
||||
});
|
||||
|
||||
test('removed items NOT in sidebar: Runtime Drift, Notifications, Watchlist', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).not.toContain('Runtime Drift');
|
||||
expect(text).not.toContain('Watchlist');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. SETTINGS GROUP
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Settings group', () => {
|
||||
test('Certificates renamed to Certificates & Trust', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/integrations');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Certificates & Trust');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. REMOVED ITEMS STILL ROUTABLE (no 404)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Removed nav items still routable', () => {
|
||||
const removedRoutes = [
|
||||
{ name: 'Replay & Verify', path: '/evidence/verify-replay' },
|
||||
{ name: 'Bundles', path: '/evidence/bundles' },
|
||||
{ name: 'Runtime Drift', path: '/ops/operations/drift' },
|
||||
{ name: 'Notifications', path: '/ops/operations/notifications' },
|
||||
{ name: 'Watchlist', path: '/ops/operations/watchlist' },
|
||||
];
|
||||
|
||||
for (const route of removedRoutes) {
|
||||
test(`${route.name} (${route.path}) loads without error`, async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, route.path);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length, `${route.name} should render content`).toBeGreaterThan(10);
|
||||
|
||||
// No Angular injection errors
|
||||
const critical = criticalOnly(errors);
|
||||
expect(critical, `${route.name} should have no critical errors`).toHaveLength(0);
|
||||
|
||||
await snap(page, `05-removed-${route.name.toLowerCase().replace(/\s+/g, '-')}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. NEW NAV ITEMS ROUTE CORRECTLY
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('New nav items route correctly', () => {
|
||||
test('Readiness → /releases/readiness renders content', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/releases/readiness');
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length).toBeGreaterThan(10);
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Findings Explorer → /security/findings renders content', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/security/findings');
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length).toBeGreaterThan(10);
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. TAB RATIONALIZATION - TRUST ADMIN (3 tabs, no Audit)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Trust Admin tabs (rationalized to 3)', () => {
|
||||
test('shows 3 tabs: Signing Keys, Trusted Issuers, Certificates', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/setup/trust-signing');
|
||||
|
||||
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
|
||||
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
const tabText = await tabBar.innerText();
|
||||
expect(tabText).toContain('Signing Keys');
|
||||
expect(tabText).toContain('Trusted Issuers');
|
||||
expect(tabText).toContain('Certificates');
|
||||
expect(tabText).not.toContain('Audit');
|
||||
}
|
||||
|
||||
await snap(page, '06-trust-admin-tabs');
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('has audit cross-link in header', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/trust-signing');
|
||||
|
||||
const auditLink = page.locator('a[href*="evidence/audit-log"]').first();
|
||||
if (await auditLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const text = await auditLink.innerText();
|
||||
expect(text.toLowerCase()).toContain('audit');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. TAB RATIONALIZATION - INTEGRATION HUB (7 tabs, no Activity/Config Audit)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Integration Hub tabs (rationalized to 7)', () => {
|
||||
test('shows 7 tabs, no Activity or Config Audit', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/setup/integrations');
|
||||
|
||||
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
|
||||
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
const tabText = await tabBar.innerText();
|
||||
expect(tabText).toContain('Hub');
|
||||
expect(tabText).toContain('Registries');
|
||||
expect(tabText).toContain('SCM');
|
||||
expect(tabText).toContain('CI/CD');
|
||||
expect(tabText).toContain('Secrets');
|
||||
expect(tabText).not.toContain('Activity');
|
||||
expect(tabText).not.toContain('Config Audit');
|
||||
}
|
||||
|
||||
await snap(page, '07-integration-tabs');
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. TAB RATIONALIZATION - POLICY GOVERNANCE (5 tabs)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Policy Governance tabs (rationalized to 5)', () => {
|
||||
test('shows 5 tabs: Risk Budget, Profiles, Configuration, Conflicts, Developer Tools', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/ops/policy/governance');
|
||||
|
||||
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
|
||||
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
const tabText = await tabBar.innerText();
|
||||
expect(tabText).toContain('Risk Budget');
|
||||
expect(tabText).toContain('Profiles');
|
||||
expect(tabText).toContain('Configuration');
|
||||
expect(tabText).toContain('Conflicts');
|
||||
expect(tabText).toContain('Developer Tools');
|
||||
|
||||
// Removed tabs should not appear
|
||||
expect(tabText).not.toContain('Trust Weights');
|
||||
expect(tabText).not.toContain('Staleness');
|
||||
expect(tabText).not.toContain('Sealed Mode');
|
||||
expect(tabText).not.toContain('Validator');
|
||||
expect(tabText).not.toContain('Playground');
|
||||
}
|
||||
|
||||
await snap(page, '08-governance-tabs');
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Configuration tab contains Trust Weights, Staleness, Sealed Mode sections', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/governance/config');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
// The config panel should show toggle buttons for the 3 sub-sections
|
||||
expect(body).toContain('Trust Weights');
|
||||
expect(body).toContain('Staleness');
|
||||
expect(body).toContain('Sealed Mode');
|
||||
|
||||
await snap(page, '09-governance-config-panel');
|
||||
});
|
||||
|
||||
test('Developer Tools tab contains Validator, Playground, Docs sections', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/governance/tools');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body).toContain('Validator');
|
||||
expect(body).toContain('Playground');
|
||||
|
||||
await snap(page, '10-governance-tools-panel');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. TAB RATIONALIZATION - POLICY SIMULATION (6 tabs)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Policy Simulation tabs (rationalized to 6)', () => {
|
||||
test('shows 6 tabs: Shadow Mode, Promotion Gate, Test & Validate, Pre-Promotion Review, Effective Policies, Exceptions', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/ops/policy/simulation');
|
||||
|
||||
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
|
||||
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
const tabText = await tabBar.innerText();
|
||||
expect(tabText).toContain('Shadow Mode');
|
||||
expect(tabText).toContain('Promotion Gate');
|
||||
expect(tabText).toContain('Test');
|
||||
expect(tabText).toContain('Effective Policies');
|
||||
expect(tabText).toContain('Exceptions');
|
||||
|
||||
// Removed standalone tabs
|
||||
expect(tabText).not.toContain('Lint');
|
||||
expect(tabText).not.toContain('Batch Evaluation');
|
||||
expect(tabText).not.toContain('History');
|
||||
}
|
||||
|
||||
await snap(page, '11-simulation-tabs');
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. QUICK ACTIONS (Ctrl+K)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Quick Actions (Ctrl+K) palette', () => {
|
||||
test('opens with Ctrl+K and shows top 5 actions', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
// Open command palette
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const palette = page.locator('app-command-palette, [class*="command-palette"]').first();
|
||||
if (await palette.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const text = await palette.innerText();
|
||||
|
||||
// Top 5 default actions should be visible
|
||||
expect(text).toContain('Scan Artifact');
|
||||
expect(text).toContain('View Findings');
|
||||
expect(text).toContain('Pending Approvals');
|
||||
expect(text).toContain('Security Posture');
|
||||
expect(text).toContain('Create Release');
|
||||
|
||||
await snap(page, '12-command-palette-default');
|
||||
}
|
||||
|
||||
// Close palette
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('no duplicate actions for scan or findings', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const palette = page.locator('app-command-palette, [class*="command-palette"]').first();
|
||||
if (await palette.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
// Type 'scan' to filter
|
||||
await page.keyboard.type('>scan');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const results = await palette.innerText();
|
||||
// Should find "Scan Artifact" but NOT "Scan Image" (removed duplicate)
|
||||
expect(results).toContain('Scan Artifact');
|
||||
expect(results).not.toContain('Scan Image');
|
||||
|
||||
await snap(page, '13-palette-scan-no-dupe');
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('new actions are discoverable: >approvals, >exceptions, >posture, >reach', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
const newShortcuts = [
|
||||
{ shortcut: '>approvals', expected: 'Pending Approvals' },
|
||||
{ shortcut: '>exceptions', expected: 'Exception Queue' },
|
||||
{ shortcut: '>posture', expected: 'Security Posture' },
|
||||
{ shortcut: '>reach', expected: 'Reachability' },
|
||||
{ shortcut: '>export', expected: 'Export Evidence' },
|
||||
{ shortcut: '>budget', expected: 'Risk Budget' },
|
||||
{ shortcut: '>envs', expected: 'Environments' },
|
||||
{ shortcut: '>exception', expected: 'Create Exception' },
|
||||
];
|
||||
|
||||
for (const { shortcut, expected } of newShortcuts) {
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const palette = page.locator('app-command-palette, [class*="command-palette"]').first();
|
||||
if (await palette.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
// Clear and type shortcut
|
||||
const input = palette.locator('input').first();
|
||||
await input.fill(shortcut);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const results = await palette.innerText();
|
||||
expect(results, `Shortcut "${shortcut}" should find "${expected}"`).toContain(expected);
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
await snap(page, '14-palette-new-actions');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 14. AUTO-EXPAND: Security group expands for /ops/policy/* routes
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Sidebar auto-expand behavior', () => {
|
||||
test('navigating to /ops/policy/governance expands Security group', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/governance');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The sidebar should have auto-expanded the Security group
|
||||
// and Risk & Governance should be visible
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const govItem = sidebar.locator('text=Risk & Governance').first();
|
||||
expect(await govItem.isVisible({ timeout: 3000 }).catch(() => false)).toBe(true);
|
||||
|
||||
await snap(page, '15-auto-expand-security');
|
||||
});
|
||||
|
||||
test('navigating to /ops/policy/packs expands Operations group', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/packs');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const packsItem = sidebar.locator('text=Policy Packs').first();
|
||||
expect(await packsItem.isVisible({ timeout: 3000 }).catch(() => false)).toBe(true);
|
||||
|
||||
await snap(page, '16-auto-expand-operations');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15. FULL NAVIGATION CLICK-THROUGH (smoke)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Full navigation smoke test', () => {
|
||||
test('click through all 6 groups without critical errors', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
// Release Control
|
||||
const deploymentsLink = page.locator('text=Deployments').first();
|
||||
if (await deploymentsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await deploymentsLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Security
|
||||
const vulnLink = page.locator('text=Vulnerabilities').first();
|
||||
if (await vulnLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await vulnLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Evidence
|
||||
const auditLink = page.locator('text=Audit Log').first();
|
||||
if (await auditLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await auditLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Operations
|
||||
const opsLink = page.locator('text=Operations Hub').first();
|
||||
if (await opsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await opsLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Settings
|
||||
const intLink = page.locator('text=Integrations').first();
|
||||
if (await intLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await intLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
await snap(page, '17-smoke-complete');
|
||||
|
||||
const critical = criticalOnly(errors);
|
||||
expect(critical, 'No critical errors during full smoke: ' + critical.join('\n')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 16. CRITICAL ROUTE RENDERING (batch)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Critical route rendering (all new/changed routes)', () => {
|
||||
const routes = [
|
||||
{ path: '/', name: 'Dashboard' },
|
||||
{ path: '/releases/deployments', name: 'Deployments' },
|
||||
{ path: '/releases', name: 'Releases' },
|
||||
{ path: '/releases/readiness', name: 'Readiness' },
|
||||
{ path: '/environments/overview', name: 'Environments' },
|
||||
{ path: '/triage/artifacts', name: 'Vulnerabilities' },
|
||||
{ path: '/security', name: 'Security Posture' },
|
||||
{ path: '/security/findings', name: 'Findings Explorer' },
|
||||
{ path: '/security/reachability', name: 'Reachability' },
|
||||
{ path: '/security/unknowns', name: 'Unknowns' },
|
||||
{ path: '/security/scan', name: 'Scan Image' },
|
||||
{ path: '/ops/policy/vex', name: 'VEX & Exceptions' },
|
||||
{ path: '/ops/policy/governance', name: 'Risk & Governance' },
|
||||
{ path: '/ops/policy/governance/config', name: 'Governance Config' },
|
||||
{ path: '/ops/policy/governance/tools', name: 'Governance Tools' },
|
||||
{ path: '/ops/policy/simulation', name: 'Policy Simulation' },
|
||||
{ path: '/ops/policy/simulation/testing', name: 'Simulation Test & Validate' },
|
||||
{ path: '/ops/policy/simulation/review', name: 'Simulation Pre-Promotion Review' },
|
||||
{ path: '/ops/policy/audit', name: 'Policy Audit' },
|
||||
{ path: '/evidence/overview', name: 'Evidence Overview' },
|
||||
{ path: '/evidence/capsules', name: 'Decision Capsules' },
|
||||
{ path: '/evidence/audit-log', name: 'Audit Log' },
|
||||
{ path: '/evidence/exports', name: 'Export Center' },
|
||||
{ path: '/ops/operations', name: 'Operations Hub' },
|
||||
{ path: '/ops/policy/packs', name: 'Policy Packs' },
|
||||
{ path: '/ops/operations/jobengine', name: 'Scheduled Jobs' },
|
||||
{ path: '/ops/operations/feeds-airgap', name: 'Feeds & Airgap' },
|
||||
{ path: '/ops/operations/doctor', name: 'Diagnostics' },
|
||||
{ path: '/setup/integrations', name: 'Integrations' },
|
||||
{ path: '/setup/identity-access', name: 'Identity & Access' },
|
||||
{ path: '/setup/trust-signing', name: 'Trust & Signing' },
|
||||
{ path: '/setup/tenant-branding', name: 'Theme & Branding' },
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
test(`${route.name} (${route.path}) renders without errors`, async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, route.path);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length, `${route.name} should have content`).toBeGreaterThan(10);
|
||||
|
||||
const critical = criticalOnly(errors);
|
||||
expect(critical, `${route.name} errors: ${critical.join('\n')}`).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 17. BACK/FORWARD NAVIGATION STABILITY
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Browser navigation stability', () => {
|
||||
test('back/forward between reorganized routes works', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
|
||||
await go(page, '/');
|
||||
await go(page, '/security');
|
||||
await go(page, '/ops/policy/governance');
|
||||
await go(page, '/evidence/audit-log');
|
||||
|
||||
// Go back 3 times
|
||||
await page.goBack({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/ops/policy/governance');
|
||||
|
||||
await page.goBack({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/security');
|
||||
|
||||
await page.goBack({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Go forward
|
||||
await page.goForward({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/security');
|
||||
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
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