Closes Phase 2. Every entity in `web/src/lib/hermes` now carries an
`instanceId: 'vijay' | 'bheem'` (with `'all'` allowed for cross-cutting
agents like Hermes Core / GitHub link), and a global instance switcher
above every Mission Control pane filters them.
Library changes (`web/src/lib/hermes.ts`):
- New `HermesInstanceId` / `HermesInstanceFilter` types + `HERMES_INSTANCES`
metadata array.
- `instanceId` added to `HermesProduct`, `HermesTask`, `HermesEvent`,
`HermesRun`, `HermesAgentStatus`. Seed data deterministically split
~50/50 across instances; agents tagged per-scope (Local VM runner →
bheem, CLI runner / Scheduler → vijay, Hermes Core / GitHub /
OpenClaw / deployment / notifications → all).
- `getHermesTasks({instance})`, `getHermesProducts(view, instance)`,
`getHermesAgents(instance)`, `getHermesHistory(instance)`,
`getHermesOverview(instance)` all accept the filter; helper
`instanceMatches(scope, filter)` keeps the semantics consistent
(always-match for `'all'` on either side).
UI changes:
- New `HermesInstanceProvider` (React context, localStorage-backed
under `hermes.instanceFilter.v1`, SSR-safe default to avoid
hydration mismatch) mounted in `app/hermes/layout.tsx`.
- New `HermesInstanceSwitcher` segmented control (radiogroup with
aria-checked) rendered in the layout header above every pane.
- New `HermesInstanceBadge` shown on task rows (Active Missions +
Task Ledger), product cards (overview minicards + portfolio
cards), and agent cards.
- `/hermes` overview gains a "Per-instance roll-up" section that
always shows Vijay vs Bheem side-by-side regardless of the active
filter — that's the always-cross-instance comparison view, while
the eight metric cards above it are filtered by the switcher.
Tests:
- 2 new unit tests in `lib/hermes.test.ts` (instance tagging on seed
data + filter semantics across tasks/products/agents/overview).
- 1 new E2E test asserting the switcher's radiogroup, default
selection, and persistence-friendly state change.
- All green: 13/13 web unit tests, 7/7 E2E.
`web/test-results/` and `web/playwright-report/` added to `.gitignore`
since they're regenerated per run.
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
122 lines
5.2 KiB
TypeScript
122 lines
5.2 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),
|
|
});
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|