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>
85 lines
3.7 KiB
TypeScript
85 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
getHermesAgents,
|
|
getHermesHistory,
|
|
getHermesOverview,
|
|
getHermesProductById,
|
|
getHermesProducts,
|
|
getHermesSettings,
|
|
getHermesTaskById,
|
|
getHermesTaskEvents,
|
|
getHermesTasks,
|
|
hermesProducts,
|
|
hermesTasks,
|
|
} from './hermes.js';
|
|
|
|
describe('hermes mock service', () => {
|
|
it('exposes a large product portfolio', () => {
|
|
expect(hermesProducts.length).toBeGreaterThanOrEqual(50);
|
|
expect(getHermesProducts('needs-attention').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('filters tasks by query and status', () => {
|
|
const blocked = getHermesTasks({ status: 'blocked' });
|
|
expect(blocked.every((task) => task.status === 'blocked')).toBe(true);
|
|
|
|
const queried = getHermesTasks({ query: 'deployment' });
|
|
expect(queried.length).toBeGreaterThan(0);
|
|
expect(queried.some((task) => task.title.toLowerCase().includes('deployment') || task.description.toLowerCase().includes('deployment'))).toBe(true);
|
|
});
|
|
|
|
it('returns task details and timeline events', () => {
|
|
const task = getHermesTaskById(hermesTasks[0].id);
|
|
expect(task).toBeDefined();
|
|
expect(getHermesTaskEvents(task!.id).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('computes overview metrics', () => {
|
|
const overview = getHermesOverview();
|
|
expect(overview.completedToday).toBeGreaterThanOrEqual(0);
|
|
expect(overview.successRate).toBeGreaterThanOrEqual(0);
|
|
expect(overview.lastAction.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('returns other mock observability slices', () => {
|
|
expect(getHermesAgents().length).toBeGreaterThan(0);
|
|
expect(getHermesHistory().length).toBeGreaterThan(0);
|
|
expect(getHermesSettings().registry.length).toBeGreaterThan(0);
|
|
expect(getHermesProductById(hermesProducts[0].id)).toBeDefined();
|
|
});
|
|
|
|
it('tags every product/task with a Hermes instance', () => {
|
|
expect(hermesProducts.every((p) => p.instanceId === 'vijay' || p.instanceId === 'bheem')).toBe(true);
|
|
expect(hermesTasks.every((t) => t.instanceId === 'vijay' || t.instanceId === 'bheem')).toBe(true);
|
|
// Both instances should be represented in the seed data so the switcher
|
|
// doesn't bottom out on either one.
|
|
expect(hermesProducts.some((p) => p.instanceId === 'vijay')).toBe(true);
|
|
expect(hermesProducts.some((p) => p.instanceId === 'bheem')).toBe(true);
|
|
});
|
|
|
|
it('filters tasks/products/agents/overview by instance', () => {
|
|
const vijayTasks = getHermesTasks({ instance: 'vijay' });
|
|
const bheemTasks = getHermesTasks({ instance: 'bheem' });
|
|
expect(vijayTasks.every((t) => t.instanceId === 'vijay')).toBe(true);
|
|
expect(bheemTasks.every((t) => t.instanceId === 'bheem')).toBe(true);
|
|
expect(vijayTasks.length + bheemTasks.length).toBe(hermesTasks.length);
|
|
|
|
const vijayProducts = getHermesProducts('all', 'vijay');
|
|
expect(vijayProducts.every((p) => p.instanceId === 'vijay')).toBe(true);
|
|
|
|
// Cross-cutting agents (scope `'all'`) appear in every per-instance view;
|
|
// strict per-instance agents only appear when their instance matches.
|
|
const vijayAgents = getHermesAgents('vijay');
|
|
const bheemAgents = getHermesAgents('bheem');
|
|
expect(vijayAgents.every((a) => a.instanceId === 'vijay' || a.instanceId === 'all')).toBe(true);
|
|
expect(bheemAgents.every((a) => a.instanceId === 'bheem' || a.instanceId === 'all')).toBe(true);
|
|
expect(vijayAgents.some((a) => a.id === 'cli-runner')).toBe(true);
|
|
expect(bheemAgents.some((a) => a.id === 'local-vm-runner')).toBe(true);
|
|
expect(vijayAgents.some((a) => a.id === 'local-vm-runner')).toBe(false);
|
|
|
|
const overviewAll = getHermesOverview('all');
|
|
const overviewVijay = getHermesOverview('vijay');
|
|
expect(overviewVijay.activeTasks).toBeLessThanOrEqual(overviewAll.activeTasks);
|
|
});
|
|
});
|