bytelyst-devops-tools/dashboard/web/e2e/hermes.spec.ts
Hermes VM 62c0cd60e0 feat(dashboard): Phase 3 slice 2 — Products pane on real service registry
Closes the "drop the fabricated 50-item mock" Phase 3 line. The Mission
Control Products pane now renders the **real** deployment registry as
its primary view, sourced from `backend/src/modules/services` (the
Cosmos-backed service registry) joined with the health module.

Page layout:
  - Top "Live services" SectionCard: real services from
    `api.getServices()` joined with `api.getHealth()`. Per-card: status
    (up / degraded / down derived from the most recent health probe),
    version, health URL, repo path, last deploy, last health check,
    response time. Refresh button (busts the 30s health cache via
    `clearHealthCache`). Loading / empty / error states. Health-check
    poll loop is intentionally not added on this page — the home
    dashboard already runs one and our cache layer dedupes.
  - Bottom "Planned products (seed data)" SectionCard: the previous
    50-item seed view, now clearly labelled `Seed` and demoted below
    the live data. Kept until manual entries for not-yet-deployed
    products are wired in (per the Phase 3 roadmap note).

E2E:
  - `hermes.spec.ts` `beforeEach` now mocks `/api/services`,
    `/api/health`, `/api/health/cache` so the products page renders
    deterministically without a live backend (the dashboard spec
    already does the same for the home page).

Verified: typecheck , 13/13 web unit tests , 7/7 E2E , lint 0
errors, build green.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 07:56:51 +00:00

135 lines
5.9 KiB
TypeScript

import { test, expect } from '@playwright/test';
const adminUser = {
id: 'user-1',
email: 'admin@example.test',
role: 'admin',
plan: 'internal',
displayName: 'Dashboard Admin',
emailVerified: true,
currentProduct: 'bytelyst-devops',
products: [{ productId: 'bytelyst-devops', plan: 'internal', role: 'admin' }],
mfaEnabled: false,
mfaMethods: [],
};
// /hermes mounts <HermesOpsPanel>, which calls api.getHermesOps() against the
// backend's `/api/hermes/ops` endpoint. The backend shells out to systemctl /
// git / ps / du on the live VM and is therefore neither available nor
// deterministic in CI. We intercept the fetch with a fixture snapshot so the
// E2E suite can run against the web stack alone.
// Shape mirrors `HermesOpsSnapshot` in `web/src/lib/api.ts` (which mirrors the
// backend Zod schema in `backend/src/modules/hermes-ops/types.ts`). Empty
// `quickLinks`/`instances` arrays are deliberate — the panel is only required
// to render without throwing in CI; the mission-control overview is what the
// suite actually asserts on.
const hermesOpsSnapshot = {
generatedAt: '2026-01-01T00:00:00.000Z',
tailscaleIp: '100.0.0.1',
emergencyDriveUpload: {
name: 'hermes-emergency-drive-upload.timer',
active: false,
nextRun: null,
lastRun: null,
},
activeSessions: { active: 0, updatedAt: '2026-01-01T00:00:00.000Z' },
cronJobs: [],
recentAlerts: [],
quickLinks: [],
instances: [],
warnings: [],
};
test.describe('Hermes Mission Control', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('access_token', 'e2e-access-token');
window.localStorage.setItem('refresh_token', 'e2e-refresh-token');
});
await page.route('**/auth/me', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(adminUser) });
});
await page.route('**/api/hermes/ops', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(hermesOpsSnapshot),
});
});
// /hermes/products fetches the real service registry + health module
// (Phase 3 slice 2). Backend isn't running in CI, so we satisfy those
// routes the same way the dashboard spec does.
await page.route('**/api/services', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
});
await page.route('**/api/health', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
});
await page.route('**/api/health/cache', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ message: 'Cache cleared' }) });
});
});
test('renders the mission control overview and navigates to companion views', async ({ page }) => {
await page.goto('/hermes');
await expect(page.getByRole('heading', { name: 'Hermes Mission Control' })).toBeVisible();
await expect(page.getByText('Active Missions')).toBeVisible();
await expect(page.getByText('Founder Attention Queue')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Product Health Snapshot' })).toBeVisible();
await page.getByRole('link', { name: 'Task Ledger' }).click();
await expect(page.getByRole('heading', { name: 'Task Ledger' })).toBeVisible();
await expect(page.getByText('Task table')).toBeVisible();
await page.goto('/hermes/tasks/task-1');
await expect(page.getByRole('heading', { name: 'Hermes learning' })).toBeVisible();
await expect(page.getByText('Timeline')).toBeVisible();
await page.goto('/hermes/products');
await expect(page.getByRole('heading', { name: 'Product Portfolio' })).toBeVisible();
await page.goto('/hermes/history');
await expect(page.getByRole('heading', { name: 'Historical Activity' })).toBeVisible();
await page.goto('/hermes/agents');
await expect(page.getByRole('heading', { name: 'Agent & Tool Observability' })).toBeVisible();
await page.goto('/hermes/settings');
await expect(page.getByRole('heading', { name: 'Settings & Configuration' })).toBeVisible();
});
test('renders the mission control overview at mobile width', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/hermes');
await expect(page.getByRole('heading', { name: 'Hermes Mission Control' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Task Ledger' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Product Portfolio' })).toBeVisible();
await page.goto('/hermes/tasks/task-1');
await expect(page.getByRole('heading', { name: 'Hermes learning' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Timeline' })).toBeVisible();
});
test('exposes a global instance switcher with All / Vijay / Bheem', async ({ page }) => {
await page.goto('/hermes');
const switcher = page.getByRole('radiogroup', { name: 'Hermes instance filter' });
await expect(switcher).toBeVisible();
// Default selection persists across navigation; "All" should be the
// active option on first visit (no localStorage entry yet).
await expect(switcher.getByRole('radio', { name: 'All' })).toHaveAttribute('aria-checked', 'true');
// Per-instance roll-up section should always be visible regardless of filter.
await expect(page.getByRole('heading', { name: 'Per-instance roll-up' })).toBeVisible();
// Switching to Bheem should still keep the page rendering correctly.
await switcher.getByRole('radio', { name: 'Bheem (uma)' }).click();
await expect(switcher.getByRole('radio', { name: 'Bheem (uma)' })).toHaveAttribute('aria-checked', 'true');
await expect(page.getByRole('heading', { name: 'Hermes Mission Control' })).toBeVisible();
});
});