Files
hermes-agent/web_console/src/app/App.test.tsx
Alexander Whitestone 8e0f24db3f
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
feat(web-console): cherry-pick React web console GUI from gary-the-ai fork
Cherry-pick the Hermes Web Console from gary-the-ai/hermes-web-console-gui.
React + TypeScript frontend with Vite, Python aiohttp backend API.

Components:
- web_console/ — React frontend (chat, sessions, memory, settings, skills,
  gateway config, cron, workspace, tools, browser, insights pages)
- gateway/web_console/ — Python backend API (23 endpoints, SSE event bus,
  11 service modules)
- gateway/platforms/api_server_ui.py — embedded browser UI for API server
- gateway/platforms/api_server.py — route registration refactored into
  _register_routes(), web console mounted via maybe_register_web_console()
- run-gui.sh / setup-gui.sh — one-command launch and setup scripts
- tests/gateway/test_api_server_gui_mount.py — 4 integration tests (passing)
- tests/web_console/ — 13 backend test files (51 passing)
- docs/plans/ — implementation plan, API schema, frontend architecture

Fix: added missing ModelContextError class and CRON_MIN_CONTEXT_TOKENS to
cron/scheduler.py (pre-existing import bug).

Closes #325
2026-04-13 18:01:51 -04:00

330 lines
14 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { App } from './App';
import { PRIMARY_NAV_ITEMS } from './router';
class MockEventSource {
url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
constructor(url: string) {
this.url = url;
MockEventSource.lastInstance = this;
}
close() {
// no-op
}
static lastInstance: MockEventSource | null = null;
simulateMessage(data: unknown) {
const payload = { data: JSON.stringify(data) } as MessageEvent;
this.onmessage?.(payload);
}
}
describe('App shell', () => {
beforeEach(() => {
global.EventSource = MockEventSource as unknown as typeof EventSource;
MockEventSource.lastInstance = null;
window.location.hash = '';
localStorage.clear();
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/gui/human/pending')) {
return new Response(JSON.stringify({ ok: true, pending: [] }), { status: 200 });
}
if (url.includes('/api/gui/chat/send')) {
return new Response(
JSON.stringify({ ok: true, session_id: 'session-live', run_id: 'run-1', status: 'started' }),
{ status: 200 }
);
}
if (url.includes('/api/gui/user-profile')) {
return new Response(
JSON.stringify({ ok: true, user_profile: { target: 'user', enabled: true, entries: ['Likes dark mode.'], entry_count: 1, usage: { text: '1%', percent: 1, current_chars: 16, char_limit: 1375 }, path: '/tmp/USER.md' } }),
{ status: 200 }
);
}
if (url.includes('/api/gui/memory')) {
return new Response(
JSON.stringify({ ok: true, memory: { target: 'memory', enabled: true, entries: ['Test memory entry.'], entry_count: 1, usage: { text: '1%', percent: 1, current_chars: 18, char_limit: 2200 }, path: '/tmp/MEMORY.md' } }),
{ status: 200 }
);
}
if (url.includes('/api/gui/session-search')) {
return new Response(JSON.stringify({ ok: true, results: [] }), { status: 200 });
}
if (url.includes('/api/gui/skills')) {
return new Response(
JSON.stringify({ ok: true, skills: [{ name: 'writing-plans', description: 'Write plans.', source_type: 'builtin' }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/cron/jobs')) {
return new Response(
JSON.stringify({ ok: true, jobs: [{ job_id: 'cron-1', name: 'Morning summary', schedule: '0 9 * * *', paused: false }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/chat/backgrounds')) {
return new Response(
JSON.stringify({ ok: true, background_runs: [{ run_id: 'run-1', session_id: 'sess-1', status: 'running', prompt: 'Analyze log files', created_at: Date.now() / 1000 }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/workspace/tree')) {
return new Response(
JSON.stringify({
ok: true,
tree: {
name: 'workspace',
path: '.',
type: 'directory',
children: [{ name: 'src/app.py', path: 'src/app.py', type: 'file' }]
}
}),
{ status: 200 }
);
}
if (url.includes('/api/gui/workspace/file')) {
return new Response(JSON.stringify({ ok: true, path: 'src/app.py', content: 'def main():\n return 1\n' }), { status: 200 });
}
if (url.includes('/api/gui/workspace/diff')) {
return new Response(JSON.stringify({ ok: true, diff: '--- a\n+++ b\n@@\n-old\n+new' }), { status: 200 });
}
if (url.includes('/api/gui/workspace/checkpoints')) {
return new Response(JSON.stringify({ ok: true, checkpoints: [{ checkpoint_id: 'cp-1', label: 'before patch' }] }), { status: 200 });
}
if (url.includes('/api/gui/processes')) {
return new Response(JSON.stringify({ ok: true, processes: [{ process_id: 'proc-1', status: 'running' }] }), { status: 200 });
}
if (url.includes('/api/gui/gateway/platforms')) {
return new Response(
JSON.stringify({ ok: true, platforms: [{ key: 'telegram', label: 'Telegram', runtime_state: 'connected', enabled: true, configured: true }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/gateway/pairing')) {
return new Response(JSON.stringify({ ok: true, pairings: [] }), { status: 200 });
}
if (url.includes('/api/gui/gateway/overview')) {
return new Response(JSON.stringify({ ok: true, overview: { summary: { platform_count: 5, connected_platforms: 2, enabled_platforms: 3 } } }), { status: 200 });
}
if (url.includes('/api/gui/settings')) {
return new Response(
JSON.stringify({ ok: true, settings: { model: 'hermes-agent', provider: 'openai-codex', browser_mode: 'local', tts_provider: 'edge' } }),
{ status: 200 }
);
}
if (url.includes('/api/gui/logs')) {
return new Response(JSON.stringify({ ok: true, lines: ['[info] hello', '[info] world'] }), { status: 200 });
}
if (url.includes('/api/gui/sessions/') && url.endsWith('/transcript')) {
return new Response(
JSON.stringify({ ok: true, items: [{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/sessions/')) {
return new Response(JSON.stringify({ ok: true, session: { title: 'Session One', recap: { preview: 'Loaded from API' } } }), {
status: 200
});
}
if (url.includes('/api/gui/sessions')) {
return new Response(
JSON.stringify({ ok: true, sessions: [{ session_id: 'sess-1', title: 'Session One', source: 'cli', last_active: 123 }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/commands')) {
return new Response(JSON.stringify({ ok: true, commands: [
{ name: 'help', description: 'Show available commands', category: 'Info', aliases: [], names: ['help'], args_hint: '', subcommands: [], cli_only: false, gateway_only: false },
{ name: 'model', description: 'Switch model for this session', category: 'Configuration', aliases: [], names: ['model'], args_hint: '[model]', subcommands: [], cli_only: false, gateway_only: false },
{ name: 'queue', description: 'Queue a prompt for the next turn', category: 'Session', aliases: ['q'], names: ['queue', 'q'], args_hint: '<prompt>', subcommands: [], cli_only: false, gateway_only: false }
] }), { status: 200 });
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}) as typeof fetch;
});
it('renders the primary navigation items', () => {
render(<App />);
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
for (const item of PRIMARY_NAV_ITEMS) {
expect(within(nav).getByRole('button', { name: new RegExp(item, 'i') })).toBeInTheDocument();
}
});
it('renders the chat page by default', () => {
render(<App />);
expect(screen.getByLabelText('Transcript')).toBeInTheDocument();
expect(screen.getByLabelText('Composer')).toBeInTheDocument();
});
it('switches route content when navigating to Sessions', async () => {
render(<App />);
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
fireEvent.click(within(nav).getByRole('button', { name: /Sessions/i }));
expect((await screen.findAllByText('Session One')).length).toBeGreaterThan(0);
});
it('switches route content when navigating to Workspace', async () => {
render(<App />);
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
fireEvent.click(within(nav).getByRole('button', { name: /Workspace/i }));
expect(await screen.findByLabelText('File tree')).toBeInTheDocument();
expect(screen.getByLabelText('Terminal panel')).toBeInTheDocument();
expect(screen.getByLabelText('Process panel')).toBeInTheDocument();
});
it('switches route content when navigating to Memory, Skills, and Automations', async () => {
render(<App />);
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
fireEvent.click(within(nav).getByRole('button', { name: /Memory/i }));
expect(await screen.findByText('Test memory entry.')).toBeInTheDocument();
fireEvent.click(within(nav).getByRole('button', { name: /Skills/i }));
const installedTab = await screen.findByRole('button', { name: /Installed & Local/i });
fireEvent.click(installedTab);
expect(await screen.findByText(/writing-plans/i)).toBeInTheDocument();
fireEvent.click(within(nav).getByRole('button', { name: /Background Jobs/i }));
expect(await screen.findByText(/Analyze log files/i)).toBeInTheDocument();
});
it('switches modal tabs when navigating inside Control Center', async () => {
render(<App />);
// Open Control Center first via title
fireEvent.click(screen.getByTitle('Control Center'));
// By default Settings form should be visible
expect(await screen.findByLabelText('Settings form')).toBeInTheDocument();
// Switch to Gateway tab
fireEvent.click(screen.getByRole('button', { name: /Messaging Gateway/i }));
expect(await screen.findByText(/Gateway Platforms/i)).toBeInTheDocument();
// Switch to Automations tab
fireEvent.click(screen.getByRole('button', { name: /Automations/i }));
expect(await screen.findByText(/Morning summary/i)).toBeInTheDocument();
});
it('opens SSE after sending a message and receives streaming events into transcript', async () => {
render(<App />);
// SSE is not created on mount; it opens after a send that returns a real session id.
const prompt = screen.getByPlaceholderText(/Message Hermes.../i);
await act(async () => {
fireEvent.change(prompt, { target: { value: 'Hello from test' } });
fireEvent.submit(screen.getByLabelText('Composer'));
});
// Wait for send to complete (Hermes is thinking indicator appears after POST resolves)
await waitFor(() => {
expect(screen.getByText(/Hermes is thinking/i)).toBeInTheDocument();
});
// SSE should now be open
const es = MockEventSource.lastInstance;
expect(es).not.toBeNull();
expect(es!.url).toContain('/api/gui/stream/session/session-live');
await act(async () => {
es!.simulateMessage({
type: 'tool.started',
session_id: 'session-live',
run_id: 'run-1',
payload: { tool_name: 'search_files', preview: 'search_files(pattern=*.py)' },
ts: Date.now() / 1000
});
});
await waitFor(() => {
expect(screen.getByText(/search_files\(pattern=\*\.py\)/)).toBeInTheDocument();
});
await act(async () => {
es!.simulateMessage({
type: 'message.assistant.completed',
session_id: 'session-live',
run_id: 'run-1',
payload: { content: 'Hermes completed analysis.' },
ts: Date.now() / 1000
});
});
});
it('toggles the inspector and drawer from the top bar', () => {
render(<App />);
const inspector = screen.getByLabelText('Inspector');
const drawer = screen.getByLabelText('Bottom drawer');
fireEvent.click(screen.getByTitle('Inspector Panel'));
expect(inspector.className).toContain('inspector-hidden');
fireEvent.click(screen.getByTitle('Terminal Drawer'));
expect(drawer.className).not.toContain('bottom-drawer-hidden');
});
it('changes inspector tabs', () => {
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'tools' }));
expect(screen.getByRole('button', { name: 'tools' }).className).toContain('panel-tab-active');
});
it('opens the command palette and prefills slash commands into chat', async () => {
render(<App />);
fireEvent.click(screen.getByTitle(/Command Palette/i));
expect(await screen.findByText(/Command Palette/i)).toBeInTheDocument();
const search = screen.getByPlaceholderText(/Search routes, actions, or commands/i);
fireEvent.change(search, { target: { value: 'model' } });
const runModelButton = screen.getAllByRole('button').find((button) => button.textContent?.includes('Run /model'));
expect(runModelButton).toBeTruthy();
fireEvent.click(runModelButton as HTMLButtonElement);
await waitFor(() => {
expect(screen.getByLabelText('Composer')).toBeInTheDocument();
const textarea = document.querySelector('#chat-prompt') as HTMLTextAreaElement | null;
expect(textarea?.value).toBe('/model ');
});
});
it('can pin actions from the command palette and show them in the pinned section', async () => {
render(<App />);
fireEvent.click(screen.getByTitle(/Command Palette/i));
expect(await screen.findByText(/Command Palette/i)).toBeInTheDocument();
const openUsageLabel = await screen.findByText(/Open Usage/i);
const openUsage = openUsageLabel.closest('[role="button"]') as HTMLElement;
const pinButton = within(openUsage).getByRole('button', { name: /☆ pin/i });
fireEvent.click(pinButton);
fireEvent.click(screen.getAllByRole('button', { name: '✕' })[0]);
fireEvent.click(screen.getByTitle(/Command Palette/i));
expect(await screen.findByText('Pinned')).toBeInTheDocument();
const pinnedHeading = screen.getByText('Pinned');
const pinnedSection = pinnedHeading.parentElement as HTMLElement;
expect(within(pinnedSection).getByText(/Open Usage/i)).toBeInTheDocument();
});
});