Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
import type { Page, Request, Route } from '@playwright/test'
|
|
|
|
export const TEST_ACCESS_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJwbGF5d3JpZ2h0Iiwicm9sZSI6InN1cGVyX2FkbWluIiwidHlwZSI6ImFjY2VzcyIsImF1ZCI6Imhlcm1lcy13ZWItdWkiLCJpYXQiOjE3NjAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMH0.playwright-signature'
|
|
|
|
export interface MockedRequest {
|
|
method: string
|
|
pathname: string
|
|
search: string
|
|
headers: Record<string, string>
|
|
postData: string | null
|
|
}
|
|
|
|
interface MockHermesApiOptions {
|
|
tokenValidationStatus?: number
|
|
initialProfileName?: 'default' | 'research'
|
|
sessions?: unknown[]
|
|
}
|
|
|
|
const sampleModelGroup = {
|
|
provider: 'test-provider',
|
|
label: 'Test Provider',
|
|
base_url: 'https://example.invalid/v1',
|
|
models: ['test-model'],
|
|
available_models: ['test-model'],
|
|
api_key: '',
|
|
builtin: true,
|
|
}
|
|
|
|
const sampleJob = {
|
|
job_id: 'job-smoke',
|
|
id: 'job-smoke',
|
|
name: 'Nightly Smoke',
|
|
prompt: 'Run the smoke check',
|
|
prompt_preview: 'Run the smoke check',
|
|
skills: [],
|
|
skill: null,
|
|
model: 'test-model',
|
|
provider: 'test-provider',
|
|
base_url: null,
|
|
script: null,
|
|
schedule: '0 9 * * *',
|
|
schedule_display: '0 9 * * *',
|
|
repeat: { times: null, completed: 0 },
|
|
enabled: true,
|
|
state: 'scheduled',
|
|
paused_at: null,
|
|
paused_reason: null,
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
next_run_at: '2026-01-02T09:00:00.000Z',
|
|
last_run_at: null,
|
|
last_status: null,
|
|
last_error: null,
|
|
deliver: 'origin',
|
|
origin: null,
|
|
last_delivery_error: null,
|
|
}
|
|
|
|
const sampleAuxiliaryModelTasks = [
|
|
{ key: 'vision', label: 'Vision', default_timeout: 120, default_download_timeout: 30 },
|
|
{ key: 'web_extract', label: 'Web extract', default_timeout: 360 },
|
|
{ key: 'compression', label: 'Compression', default_timeout: 120 },
|
|
{ key: 'skills_hub', label: 'Skills hub', default_timeout: 30 },
|
|
{ key: 'approval', label: 'Approval', default_timeout: 30 },
|
|
{ key: 'mcp', label: 'MCP', default_timeout: 30 },
|
|
{ key: 'title_generation', label: 'Title generation', default_timeout: 30 },
|
|
{ key: 'triage_specifier', label: 'Triage specifier', default_timeout: 120 },
|
|
{ key: 'kanban_decomposer', label: 'Kanban decomposer', default_timeout: 180 },
|
|
{ key: 'profile_describer', label: 'Profile describer', default_timeout: 60 },
|
|
{ key: 'curator', label: 'Curator', default_timeout: 600 },
|
|
{ key: 'session_search', label: 'Session search', default_timeout: 30 },
|
|
{ key: 'flush_memories', label: 'Flush memories', default_timeout: 30 },
|
|
]
|
|
|
|
function jsonResponse(body: unknown, status = 200) {
|
|
return {
|
|
status,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(body),
|
|
}
|
|
}
|
|
|
|
function recordRequest(request: Request): MockedRequest {
|
|
const url = new URL(request.url())
|
|
return {
|
|
method: request.method(),
|
|
pathname: url.pathname,
|
|
search: url.search,
|
|
headers: request.headers(),
|
|
postData: request.postData(),
|
|
}
|
|
}
|
|
|
|
export async function mockHermesApi(page: Page, options: MockHermesApiOptions = {}) {
|
|
const requests: MockedRequest[] = []
|
|
const unexpectedRequests: MockedRequest[] = []
|
|
const tokenValidationStatus = options.tokenValidationStatus ?? 200
|
|
let activeProfileName = options.initialProfileName ?? 'research'
|
|
|
|
await page.route('**/*', async (route: Route) => {
|
|
const request = route.request()
|
|
const url = new URL(request.url())
|
|
const { pathname } = url
|
|
|
|
if (!(pathname === '/health' || pathname.startsWith('/api/') || pathname.startsWith('/v1/'))) {
|
|
await route.continue()
|
|
return
|
|
}
|
|
|
|
requests.push(recordRequest(request))
|
|
|
|
if (pathname === '/health') {
|
|
await route.fulfill(jsonResponse({ status: 'ok', webui_version: '0.5.23', node_version: '23.0.0' }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/auth/status') {
|
|
await route.fulfill(jsonResponse({ hasPasswordLogin: true, username: 'playwright' }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/auth/login') {
|
|
if (request.method() !== 'POST') {
|
|
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
|
return
|
|
}
|
|
if (tokenValidationStatus !== 200) {
|
|
await route.fulfill(jsonResponse({ error: 'Invalid username or password' }, tokenValidationStatus))
|
|
return
|
|
}
|
|
await route.fulfill(jsonResponse({ token: TEST_ACCESS_KEY }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/auth/me') {
|
|
await route.fulfill(jsonResponse({
|
|
user: {
|
|
id: 1,
|
|
username: 'playwright',
|
|
role: 'super_admin',
|
|
status: 'active',
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
last_login_at: 0,
|
|
avatar: '',
|
|
},
|
|
}))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/auth/avatar') {
|
|
if (request.method() === 'GET') {
|
|
await route.fulfill(jsonResponse({ avatar: '' }))
|
|
return
|
|
}
|
|
if (request.method() === 'PUT') {
|
|
await route.fulfill(jsonResponse({ success: true, avatar: '' }))
|
|
return
|
|
}
|
|
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/sessions') {
|
|
await route.fulfill(jsonResponse({ sessions: options.sessions ?? [] }, tokenValidationStatus))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/sessions/hermes') {
|
|
await route.fulfill(jsonResponse({ sessions: [] }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/sessions/context-length') {
|
|
await route.fulfill(jsonResponse({ context_length: 256000 }))
|
|
return
|
|
}
|
|
|
|
if (/^\/api\/hermes\/sessions\/[^/]+\/workspace-run-changes$/.test(pathname)) {
|
|
await route.fulfill(jsonResponse({ changes: [] }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/files/list') {
|
|
await route.fulfill(jsonResponse({ entries: [], path: '' }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/auth/copilot/check-token') {
|
|
await route.fulfill(jsonResponse({ has_token: false, source: null, enabled: false }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/auth/locked-ips') {
|
|
await route.fulfill(jsonResponse({ locks: [] }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/available-models') {
|
|
await route.fulfill(jsonResponse({
|
|
default: 'test-model',
|
|
default_provider: 'test-provider',
|
|
groups: [sampleModelGroup],
|
|
allProviders: [sampleModelGroup],
|
|
model_aliases: {},
|
|
model_visibility: {},
|
|
}))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/provider-models') {
|
|
await route.fulfill(jsonResponse({ models: ['proxy-model-a', 'proxy-model-b'] }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/config/auxiliary-models') {
|
|
await route.fulfill(jsonResponse({ tasks: sampleAuxiliaryModelTasks, auxiliary: {} }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/pets/active') {
|
|
await route.fulfill(jsonResponse({ pet: null }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/profiles') {
|
|
await route.fulfill(jsonResponse({
|
|
profiles: [
|
|
{ name: 'default', active: activeProfileName === 'default', model: 'test-model', gateway: 'test', alias: 'Default' },
|
|
{ name: 'research', active: activeProfileName === 'research', model: 'test-model', gateway: 'test', alias: 'Research' },
|
|
],
|
|
}))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/profiles/runtime-statuses') {
|
|
await route.fulfill(jsonResponse({
|
|
profiles: [
|
|
{
|
|
profile: 'default',
|
|
bridge: { running: activeProfileName === 'default', profile: 'default', reachable: true },
|
|
gateway: { running: true, profile: 'default' },
|
|
},
|
|
{
|
|
profile: 'research',
|
|
bridge: { running: activeProfileName === 'research', profile: 'research', reachable: true },
|
|
gateway: { running: true, profile: 'research' },
|
|
},
|
|
],
|
|
}))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/profiles/active') {
|
|
if (request.method() !== 'PUT') {
|
|
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
|
return
|
|
}
|
|
|
|
let body: { name?: unknown }
|
|
try {
|
|
body = JSON.parse(request.postData() || '{}')
|
|
} catch {
|
|
await route.fulfill(jsonResponse({ error: 'Invalid JSON body' }, 400))
|
|
return
|
|
}
|
|
|
|
if (body.name !== 'default' && body.name !== 'research') {
|
|
await route.fulfill(jsonResponse({ error: 'Unknown profile' }, 400))
|
|
return
|
|
}
|
|
|
|
activeProfileName = body.name
|
|
await route.fulfill(jsonResponse({ success: true, active: activeProfileName }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/config') {
|
|
await route.fulfill(jsonResponse({
|
|
display: { streaming: true, show_reasoning: true, show_cost: true },
|
|
agent: {},
|
|
memory: {},
|
|
session_reset: {},
|
|
privacy: {},
|
|
approvals: {},
|
|
}))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/hermes/jobs') {
|
|
await route.fulfill(jsonResponse({ jobs: [sampleJob] }))
|
|
return
|
|
}
|
|
|
|
if (pathname === '/api/cron-history') {
|
|
await route.fulfill(jsonResponse({ runs: [] }))
|
|
return
|
|
}
|
|
|
|
unexpectedRequests.push(recordRequest(request))
|
|
await route.fulfill(jsonResponse({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404))
|
|
})
|
|
|
|
return { requests, unexpectedRequests }
|
|
}
|
|
|
|
export async function authenticate(page: Page, accessKey = TEST_ACCESS_KEY, profileName?: string) {
|
|
await page.addInitScript((state: { storedToken: string; storedProfileName?: string }) => {
|
|
const { storedToken, storedProfileName } = state
|
|
window.localStorage.setItem('hermes_api_key', storedToken)
|
|
if (storedProfileName && !window.localStorage.getItem('hermes_active_profile_name')) {
|
|
window.localStorage.setItem('hermes_active_profile_name', storedProfileName)
|
|
}
|
|
}, { storedToken: accessKey, storedProfileName: profileName })
|
|
}
|
|
|
|
export async function mockChatSocket(page: Page) {
|
|
await page.route('**/node_modules/.vite/deps/socket__io-client.js*', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/javascript',
|
|
body: `
|
|
const state = window.__PW_CHAT_SOCKET__ || (window.__PW_CHAT_SOCKET__ = { sockets: [], emitted: [] })
|
|
function makeSocket(url, options) {
|
|
const listeners = new Map()
|
|
const onceListeners = new Map()
|
|
const socket = {
|
|
connected: true,
|
|
url,
|
|
options,
|
|
on(event, handler) {
|
|
const handlers = listeners.get(event) || []
|
|
handlers.push(handler)
|
|
listeners.set(event, handlers)
|
|
return this
|
|
},
|
|
once(event, handler) {
|
|
const handlers = onceListeners.get(event) || []
|
|
handlers.push(handler)
|
|
onceListeners.set(event, handlers)
|
|
return this
|
|
},
|
|
emit(event, payload) {
|
|
state.emitted.push({ event, payload })
|
|
if (event === 'resume') {
|
|
const sessionId = payload && payload.session_id
|
|
const resumes = window.__PW_CHAT_SOCKET_RESUMES__ || {}
|
|
const response = sessionId ? resumes[sessionId] : null
|
|
if (response) {
|
|
setTimeout(() => this.__trigger('resumed', response), 0)
|
|
}
|
|
}
|
|
return this
|
|
},
|
|
removeAllListeners() {
|
|
listeners.clear()
|
|
onceListeners.clear()
|
|
return this
|
|
},
|
|
disconnect() {
|
|
this.connected = false
|
|
return this
|
|
},
|
|
__trigger(event, payload) {
|
|
for (const handler of listeners.get(event) || []) handler(payload)
|
|
const handlers = onceListeners.get(event) || []
|
|
onceListeners.delete(event)
|
|
for (const handler of handlers) handler(payload)
|
|
},
|
|
}
|
|
state.sockets.push(socket)
|
|
state.latest = socket
|
|
return socket
|
|
}
|
|
export function io(url, options) {
|
|
return makeSocket(url, options)
|
|
}
|
|
export default { io }
|
|
`,
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function mockTerminalWebSocket(page: Page) {
|
|
await page.addInitScript(() => {
|
|
const state = (window as any).__PW_TERMINAL_WS__ = {
|
|
sockets: [] as any[],
|
|
sent: [] as any[],
|
|
createdCount: 0,
|
|
latest: null as any,
|
|
}
|
|
const RealEvent = window.Event
|
|
const RealMessageEvent = window.MessageEvent
|
|
|
|
class MockTerminalWebSocket extends EventTarget {
|
|
static CONNECTING = 0
|
|
static OPEN = 1
|
|
static CLOSING = 2
|
|
static CLOSED = 3
|
|
|
|
readonly CONNECTING = 0
|
|
readonly OPEN = 1
|
|
readonly CLOSING = 2
|
|
readonly CLOSED = 3
|
|
binaryType: BinaryType = 'blob'
|
|
bufferedAmount = 0
|
|
extensions = ''
|
|
protocol = ''
|
|
readyState = MockTerminalWebSocket.CONNECTING
|
|
onopen: ((event: Event) => void) | null = null
|
|
onmessage: ((event: MessageEvent) => void) | null = null
|
|
onerror: ((event: Event) => void) | null = null
|
|
onclose: ((event: CloseEvent) => void) | null = null
|
|
|
|
constructor(readonly url: string | URL) {
|
|
super()
|
|
state.sockets.push(this)
|
|
state.latest = this
|
|
setTimeout(() => {
|
|
this.readyState = MockTerminalWebSocket.OPEN
|
|
const openEvent = new RealEvent('open')
|
|
this.onopen?.(openEvent)
|
|
this.dispatchEvent(openEvent)
|
|
this.__createSession('term-1', 'zsh', 101)
|
|
}, 0)
|
|
}
|
|
|
|
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
|
|
const normalized = typeof data === 'string' ? data : String(data)
|
|
state.sent.push({ socket: this.url.toString(), data: normalized })
|
|
if (normalized.charCodeAt(0) !== 0x7B) return
|
|
try {
|
|
const message = JSON.parse(normalized)
|
|
if (message.type === 'create') {
|
|
this.__createSession(`term-${state.createdCount + 1}`, 'bash', 200 + state.createdCount)
|
|
}
|
|
if (message.type === 'switch') {
|
|
this.__emitMessage(JSON.stringify({ type: 'switched', id: message.sessionId }))
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
close() {
|
|
this.readyState = MockTerminalWebSocket.CLOSED
|
|
}
|
|
|
|
__createSession(id: string, shell: string, pid: number) {
|
|
state.createdCount += 1
|
|
this.__emitMessage(JSON.stringify({ type: 'created', id, shell, pid }))
|
|
}
|
|
|
|
__emitMessage(data: string) {
|
|
const event = new RealMessageEvent('message', { data })
|
|
this.onmessage?.(event)
|
|
this.dispatchEvent(event)
|
|
}
|
|
}
|
|
|
|
;(window as any).WebSocket = MockTerminalWebSocket
|
|
})
|
|
}
|