Consolidate Operations UI, rename Policy Packs to Release Policies, add host infrastructure
Five sprints delivered in this change: Sprint 001 - Ops UI Consolidation: Remove Operations Hub, Agents Fleet Dashboard, and Signals Runtime Dashboard (31 files deleted). Ops nav goes from 8 to 4 items. Redirects from old routes. Sprint 002 - Host Infrastructure (Backend): Add SshHostConfig and WinRmHostConfig target connection types with validation. Implement AgentInventoryCollector (real IInventoryCollector that parses docker ps JSON via IRemoteCommandExecutor abstraction). Enrich TopologyHostProjection with ProbeStatus/ProbeType/ProbeLastHeartbeat fields. Sprint 003 - Host UI + Environment Verification: Add runtime verification column to environment target list with Verified/Drift/ Offline/Unmonitored badges. Add container-level verification detail to Deploy Status tab showing deployed vs running digests with drift highlighting. Sprint 004 - Release Policies Rename: Move "Policy Packs" from Ops to Release Control as "Release Policies". Remove "Risk & Governance" from Security nav. Rename Pack Registry to Automation Catalog. Create gate-catalog.ts with 11 gate type display names and descriptions. Sprint 005 - Policy Builder: Create visual policy builder (3-step: name, gates, review) with per-gate-type config forms (CVSS threshold slider, signature toggles, freshness days, etc). Simplify pack workspace tabs from 6 to 3 (Rules, Test, Activate). Add YAML toggle within Rules tab. 59/59 Playwright e2e tests pass across 4 test suites. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
220
src/Web/StellaOps.Web/e2e/ops-consolidation.e2e.spec.ts
Normal file
220
src/Web/StellaOps.Web/e2e/ops-consolidation.e2e.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Operations UI Consolidation — Docker Stack E2E Tests
|
||||
*
|
||||
* Uses the auth fixture for authenticated page access.
|
||||
* Run against live Docker stack: npx playwright test --config playwright.e2e.config.ts ops-consolidation
|
||||
*
|
||||
* Verifies:
|
||||
* - Removed pages are gone (Operations Hub, Agent Fleet, Signals)
|
||||
* - Sidebar Ops section has exactly 5 items
|
||||
* - Old routes redirect (not 404)
|
||||
* - Remaining ops pages render
|
||||
* - Topology pages unaffected
|
||||
* - No Angular runtime errors
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait, assertPageHasContent, getPageHeading } from './helpers/nav.helper';
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/ops-consolidation';
|
||||
|
||||
function collectNgErrors(page: import('@playwright/test').Page) {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text();
|
||||
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
|
||||
errors.push(text);
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Sidebar: removed items are gone
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Ops sidebar cleanup verification', () => {
|
||||
test('Operations Hub is removed from sidebar', async ({ authenticatedPage: page }) => {
|
||||
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sidebar).not.toContainText('Operations Hub');
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/01-no-ops-hub.png`, fullPage: true });
|
||||
});
|
||||
|
||||
test('Agent Fleet is removed from sidebar', async ({ authenticatedPage: page }) => {
|
||||
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sidebar).not.toContainText('Agent Fleet');
|
||||
});
|
||||
|
||||
test('Signals is removed from sidebar nav items', async ({ authenticatedPage: page }) => {
|
||||
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
|
||||
const navItems = page.locator('aside.sidebar a.nav-item');
|
||||
const count = await navItems.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = ((await navItems.nth(i).textContent()) ?? '').trim();
|
||||
expect(text).not.toBe('Signals');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Sidebar: expected 5 items present
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Ops sidebar has correct items', () => {
|
||||
const EXPECTED_ITEMS = [
|
||||
'Policy Packs',
|
||||
'Scheduled Jobs',
|
||||
'Feeds & Airgap',
|
||||
'Scripts',
|
||||
'Diagnostics',
|
||||
];
|
||||
|
||||
test(`sidebar Ops section contains all ${EXPECTED_ITEMS.length} expected items`, async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
|
||||
const sidebarText = (await page.locator('aside.sidebar').textContent()) ?? '';
|
||||
for (const label of EXPECTED_ITEMS) {
|
||||
expect(sidebarText, `Should contain "${label}"`).toContain(label);
|
||||
}
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/02-ops-nav-5-items.png`, fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Redirects: old routes handled gracefully
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Old route redirects', () => {
|
||||
test('/ops/operations redirects to jobs-queues', async ({ authenticatedPage: page }) => {
|
||||
await navigateAndWait(page, '/ops/operations', { timeout: 30_000 });
|
||||
expect(page.url()).toContain('/ops/operations/jobs-queues');
|
||||
await assertPageHasContent(page);
|
||||
});
|
||||
|
||||
test('/ops/signals redirects to diagnostics', async ({ authenticatedPage: page }) => {
|
||||
await navigateAndWait(page, '/ops/signals', { timeout: 30_000 });
|
||||
expect(page.url()).toContain('/ops/operations/doctor');
|
||||
await assertPageHasContent(page);
|
||||
});
|
||||
|
||||
test('/ops base redirects through to content', async ({ authenticatedPage: page }) => {
|
||||
await navigateAndWait(page, '/ops', { timeout: 30_000 });
|
||||
await assertPageHasContent(page);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Remaining ops pages render
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Remaining Ops pages render', () => {
|
||||
const PAGES = [
|
||||
{ path: '/ops/operations/jobs-queues', name: 'Jobs & Queues' },
|
||||
{ path: '/ops/operations/feeds-airgap', name: 'Feeds & AirGap' },
|
||||
{ path: '/ops/operations/doctor', name: 'Diagnostics' },
|
||||
{ path: '/ops/operations/data-integrity', name: 'Data Integrity' },
|
||||
{ path: '/ops/operations/event-stream', name: 'Event Stream' },
|
||||
{ path: '/ops/operations/jobengine', name: 'JobEngine Dashboard' },
|
||||
];
|
||||
|
||||
for (const route of PAGES) {
|
||||
test(`${route.name} (${route.path}) renders`, async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = collectNgErrors(page);
|
||||
await navigateAndWait(page, route.path, { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
await assertPageHasContent(page);
|
||||
expect(
|
||||
ngErrors,
|
||||
`Angular errors on ${route.path}: ${ngErrors.join('\n')}`,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Topology pages unaffected
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Topology pages still work', () => {
|
||||
const TOPOLOGY_PAGES = [
|
||||
{ path: '/setup/topology/overview', name: 'Overview' },
|
||||
{ path: '/setup/topology/hosts', name: 'Hosts' },
|
||||
{ path: '/setup/topology/targets', name: 'Targets' },
|
||||
{ path: '/setup/topology/agents', name: 'Agents' },
|
||||
];
|
||||
|
||||
for (const route of TOPOLOGY_PAGES) {
|
||||
test(`topology ${route.name} (${route.path}) renders`, async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
const ngErrors = collectNgErrors(page);
|
||||
await navigateAndWait(page, route.path, { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
await assertPageHasContent(page);
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('operations/agents loads topology agents (not fleet dashboard)', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
const ngErrors = collectNgErrors(page);
|
||||
await navigateAndWait(page, '/ops/operations/agents', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
await assertPageHasContent(page);
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/03-agents-topology.png`, fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Full journey stability
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Ops navigation journey', () => {
|
||||
test('sequential navigation across all ops routes has no Angular errors', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const ngErrors = collectNgErrors(page);
|
||||
|
||||
const routes = [
|
||||
'/ops/operations/jobs-queues',
|
||||
'/ops/operations/feeds-airgap',
|
||||
'/ops/operations/doctor',
|
||||
'/ops/operations/data-integrity',
|
||||
'/ops/operations/agents',
|
||||
'/ops/operations/event-stream',
|
||||
'/ops/integrations',
|
||||
'/ops/policy',
|
||||
'/setup/topology/overview',
|
||||
'/setup/topology/hosts',
|
||||
'/setup/topology/agents',
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
await navigateAndWait(page, route, { timeout: 30_000 });
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
expect(
|
||||
ngErrors,
|
||||
`Angular errors during journey: ${ngErrors.join('\n')}`,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('browser back/forward works across ops routes', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
|
||||
await navigateAndWait(page, '/ops/operations/doctor', { timeout: 30_000 });
|
||||
await navigateAndWait(page, '/ops/operations/feeds-airgap', { timeout: 30_000 });
|
||||
|
||||
await page.goBack();
|
||||
await page.waitForTimeout(1500);
|
||||
expect(page.url()).toContain('/doctor');
|
||||
|
||||
await page.goForward();
|
||||
await page.waitForTimeout(1500);
|
||||
expect(page.url()).toContain('/feeds-airgap');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user