refactor(desktop-e2e): one mock inference server in tests-js/scripts

The dev:mock script duplicated the e2e mock server. The copy had only
the plain chat reply; every scripted path lived only in the e2e version.
A single mock server now lives in tests-js/scripts/mock-server.ts.
The e2e suite imports it as a library. Running the file directly
starts the server, writes a mock config, and launches the desktop app.
The dev:mock script now runs that file.
The e2e tsconfig lists tests-js/scripts in its include, because the
composite project rule requires every imported file to be listed.
This commit is contained in:
ethernet
2026-08-12 10:15:25 -04:00
parent 6e139b2c22
commit fd02bd8621
17 changed files with 196 additions and 253 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
import { expect, test } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from '../../../tests-js/scripts/mock-server'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
@@ -10,7 +10,7 @@ import { type TestInfo } from '@playwright/test'
import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from './mock-server'
import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from '../../../tests-js/scripts/mock-server'
const OTHER_SESSION_PROMPT = 'E2E persisted session used for a warm resume.'
const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must remain singular after a correction.`
+1 -1
View File
@@ -27,7 +27,7 @@ import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer, type MockServerOptions } from './mock-server'
import { startMockServer, type MockServerOptions } from '../../../tests-js/scripts/mock-server'
import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
@@ -25,7 +25,7 @@ import {
startMockServer,
VERIFICATION_STOP_TEXT,
VERIFICATION_STOP_TRIGGER,
} from './mock-server'
} from '../../../tests-js/scripts/mock-server'
import { RealSessionBuilder } from './real-session-builder'
const SESSION_TITLE = 'E2E Hidden History Messages'
@@ -23,7 +23,7 @@ import {
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import { type MockServer, startMockServer } from './mock-server'
import { type MockServer, startMockServer } from '../../../tests-js/scripts/mock-server'
import { RealSessionBuilder } from './real-session-builder'
import { type ElectronApplication, expect, type Page, test } from './test'
+1 -1
View File
@@ -36,7 +36,7 @@ import {
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { INTERIM_TEXTS, restartMockServer } from './mock-server'
import { INTERIM_TEXTS, restartMockServer } from '../../../tests-js/scripts/mock-server'
// ─── Helpers ──────────────────────────────────────────────────────────
@@ -13,7 +13,7 @@ import {
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from '../../../tests-js/scripts/mock-server'
import { RealSessionBuilder } from './real-session-builder'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
+1 -1
View File
@@ -10,7 +10,7 @@
import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { MOCK_REPLY } from './mock-server'
import { MOCK_REPLY } from '../../../tests-js/scripts/mock-server'
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
@@ -5,7 +5,7 @@
import { expect, test, type Page } from '@playwright/test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from '../../../tests-js/scripts/mock-server'
async function send(page: Page, text: string, delay = 15): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
+1 -1
View File
@@ -21,7 +21,7 @@ import {
restartMockServer,
SIDEBAR_CROSS_TEXTS,
SIDEBAR_TEXTS,
} from './mock-server'
} from '../../../tests-js/scripts/mock-server'
/** Background-running dot aria-label (from i18n en.ts). */
const BG_DOT_LABEL = 'Background task running'
+1 -1
View File
@@ -27,7 +27,7 @@ import {
createBackgroundReleaseHandle,
restartMockServer,
SIDEBAR_CROSS_TEXTS,
} from './mock-server'
} from '../../../tests-js/scripts/mock-server'
/** Finished-unread dot aria-label. */
const UNREAD_DOT_LABEL = 'Finished — unread'
+1 -1
View File
@@ -41,7 +41,7 @@ import {
buildAppEnv,
launchDesktop,
} from './fixtures'
import { startMockServer } from './mock-server'
import { startMockServer } from '../../../tests-js/scripts/mock-server'
import { RealSessionBuilder } from './real-session-builder'
const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
@@ -11,7 +11,7 @@ import {
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import { startMockServer } from './mock-server'
import { startMockServer } from '../../../tests-js/scripts/mock-server'
import { expect, test } from './test'
import { expectVisualSnapshot } from './visual-snapshot'
+1 -1
View File
@@ -17,7 +17,7 @@
"clean:electron": "tsc --build tsconfig.electron.json --clean",
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:mock": "node scripts/dev-mock.mjs",
"dev:mock": "node ../../tests-js/scripts/mock-server.ts",
"dev:renderer": "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port 5174",
"dev:electron": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
-237
View File
@@ -1,237 +0,0 @@
#!/usr/bin/env node
/**
* Launch the desktop app with a mock inference provider — no real API
* keys needed. Starts a local OpenAI-compatible server that returns a
* canned reply, writes an isolated config.yaml + .env, and launches the
* built Electron app against them.
*
* This reuses the same mock-server and config format as the E2E fixtures
* (apps/desktop/e2e/mock-server.ts + fixtures.ts), so local dev and CI
* test the same chain.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*
* Usage:
* node scripts/dev-mock.mjs
* npm run dev:mock
*
* The mock server listens on an ephemeral port and replies to every
* chat completion with:
* "Hello from the mock inference server! The full boot chain is working."
*/
import http from 'node:http'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
// ── Canned reply ───────────────────────────────────────────────────────
const CANNED_REPLY =
'Hello from the mock inference server! The full boot chain is working.'
// ── Mock server (mirrors e2e/mock-server.ts) ───────────────────────────
function startMockServer() {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
object: 'list',
data: [{ id: 'mock-model', object: 'model', created: 0, owned_by: 'mock' }],
}),
)
return
}
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
let body = ''
req.on('data', (chunk) => { body += chunk.toString() })
req.on('end', () => {
let parsed = {}
try { parsed = JSON.parse(body) } catch { /* non-streaming */ }
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
res.write(
`data: ${JSON.stringify({
id: 'mock-completion', object: 'chat.completion.chunk',
created: 0, model,
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion', object: 'chat.completion.chunk',
created: 0, model,
choices: [{ index: 0, delta: { content: word }, finish_reason: null }],
})}\n\n`,
)
i++
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion', object: 'chat.completion',
created: 0, model,
choices: [{
index: 0,
message: { role: 'assistant', content: CANNED_REPLY },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
)
}
})
req.on('error', () => { res.writeHead(400); res.end('Bad request') })
return
}
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr === null || typeof addr === 'string') {
reject(new Error('Failed to get server address'))
return
}
resolve({ port: addr.port, url: `http://127.0.0.1:${addr.port}`, close: () => server.close() })
})
})
}
// ── Config + env writing (mirrors e2e/fixtures.ts) ─────────────────────
function createSandbox() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-dev-mock-${Date.now()}`))
const hermesHome = path.join(root, 'hermes-home')
const userDataDir = path.join(root, 'electron-user-data')
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(userDataDir, { recursive: true })
return { root, hermesHome, userDataDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
}
function writeMockConfig(hermesHome, mockUrl) {
fs.writeFileSync(
path.join(hermesHome, 'config.yaml'),
`# Auto-generated by dev-mock.mjs
model:
default: mock-model
provider: mock
providers:
mock:
api: ${mockUrl}/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`,
'utf8',
)
fs.writeFileSync(path.join(hermesHome, '.env'), 'MOCK_API_KEY=e2e-mock-key\n', 'utf8')
}
// ── Electron launch ────────────────────────────────────────────────────
function findElectron() {
const local = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
if (fs.existsSync(local)) return local
const r = spawnSync('which', ['electron'], { encoding: 'utf8' })
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim()
throw new Error('Electron binary not found. Run "npm install" from the repo root.')
}
function assertDistBuilt() {
const electronMain = path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs')
const indexHtml = path.join(DESKTOP_ROOT, 'dist', 'index.html')
if (!fs.existsSync(electronMain) || !fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
}
// ── Main ───────────────────────────────────────────────────────────────
async function main() {
assertDistBuilt()
console.log('Starting mock inference server...')
const mock = await startMockServer()
console.log(` Mock server: ${mock.url}`)
const sandbox = createSandbox()
writeMockConfig(sandbox.hermesHome, mock.url)
console.log(` HERMES_HOME: ${sandbox.hermesHome}`)
const electronBin = findElectron()
const env = {
...process.env,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesDevMock-${Date.now()}`,
}
console.log('Launching Electron...')
const child = spawn(electronBin, [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], {
env,
cwd: DESKTOP_ROOT,
stdio: 'inherit',
})
child.on('exit', (code) => {
mock.close()
sandbox.cleanup()
process.exit(code ?? 0)
})
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+1 -1
View File
@@ -4,6 +4,6 @@
"types": ["node", "@playwright/test"],
"composite": true
},
"include": ["e2e", "playwright.config.ts"],
"include": ["e2e", "playwright.config.ts", "../../tests-js/scripts"],
"exclude": ["src", "electron"]
}
@@ -1,5 +1,6 @@
/**
* Minimal OpenAI-compatible mock inference server for E2E tests.
* Minimal OpenAI-compatible mock inference server for E2E tests and the
* dev:mock dev flow.
*
* Implements just enough of the /v1/* surface for `hermes serve` to resolve a
* provider, list models, and stream a canned chat completion back to the
@@ -12,13 +13,19 @@
* The canned response is a short, deterministic assistant message. Tool-call
* requests are not simulated — the E2E tests only need the chat surface to
* prove the full boot → gateway → inference → renderer chain works.
*
* Import the module to get the server as a library (the Playwright E2E
* suite). Run the file directly to also write an isolated mock config and
* launch the built Electron app against it (`npm run dev:mock`).
*/
import { spawn, spawnSync } from 'node:child_process'
import fs from 'node:fs'
import http from 'node:http'
import type { ServerResponse } from 'node:http'
import os from 'node:os'
import nodePath from 'node:path'
import { pathToFileURL } from 'node:url'
/** A canned assistant reply used for every chat completion request. */
export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
@@ -200,10 +207,12 @@ function sidebarCrossBgCommand(releasePath?: string): string {
if (!releasePath) {
return 'echo "long bg output" && sleep 5 && echo "finished"'
}
// Bounded wait (60s): if a test forgets to release (or crashes mid-way),
// the process still exits instead of hanging the worker until the suite
// times out.
const quoted = JSON.stringify(releasePath)
return [
'echo "long bg output"',
`for _ in $(seq 1 600); do [ -e ${quoted} ] && break; sleep 0.1; done`,
@@ -327,12 +336,15 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
let resolveHeldStreamStarted: (() => void) | null = null
let releaseHeldStream: (() => void) | null = null
let heldCompletionCount = 0
const heldStreamStarted = new Promise<void>(resolveHeld => {
resolveHeldStreamStarted = resolveHeld
})
const heldStreamReleased = new Promise<void>(resolveRelease => {
releaseHeldStream = resolveRelease
})
const server = http.createServer((req, res) => {
// CORS headers — the Electron renderer doesn't need them, but they
// don't hurt and make the server usable from a browser context too.
@@ -343,6 +355,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
@@ -362,6 +375,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
],
}),
)
return
}
@@ -392,6 +406,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
const holdThisCompletion = Boolean(
options.holdFirstCompletionContaining &&
heldCompletionCount === 0 &&
@@ -407,16 +422,20 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
const messages: any[] = Array.isArray(parsed.messages) ? parsed.messages : []
const lastUserMsg = [...messages].reverse().find(m => m?.role === 'user')
const userText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : ''
if (userText) {
_receivedUserTexts.push(userText)
}
const isInterimTrigger = userText.includes('E2E_INTERIM_TRIGGER')
const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER')
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
const isVerificationStopTrigger = messages.some(
message => typeof message?.content === 'string' && message.content.includes(VERIFICATION_STOP_TRIGGER),
)
const isCorrectionSwitchTrigger = messages.some(
message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER),
)
@@ -427,17 +446,20 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
} else {
nonStreamingScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
}
return
}
if (isQueueStopTrigger) {
const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1]
_queueStopIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
@@ -445,22 +467,26 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
const script = verificationStopScript(options.verificationWritePath ?? 'e2e-verification-target.py')
const turn = script[_verificationStopIndex] ?? script[script.length - 1]
_verificationStopIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isCorrectionSwitchTrigger) {
const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1]
_correctionSwitchIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
@@ -474,6 +500,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
@@ -486,17 +513,20 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isInterimTrigger) {
const turn = INTERIM_SCRIPT[_scriptIndex] ?? INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1]
_scriptIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
@@ -505,11 +535,14 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
)
streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion ? () => {
if (holdThisCompletion) {
heldCompletionCount++
}
resolveHeldStreamStarted?.()
return heldStreamReleased
} : undefined)
} else {
@@ -527,6 +560,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
res.writeHead(400)
res.end('Bad request')
})
return
}
@@ -539,8 +573,10 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr === null || typeof addr === 'string') {
reject(new Error('Failed to get server address'))
return
}
@@ -607,16 +643,20 @@ function streamTextResponse(
res.write(sseChunk(model, {}, 'stop'))
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(sseChunk(model, { content: word }))
i++
if (waitForRelease && i === 1) {
waitForRelease().then(() => setTimeout(sendChunk, 20))
return
}
setTimeout(sendChunk, 20)
}
@@ -683,8 +723,10 @@ function streamScriptedTurn(
} else {
res.write(sseChunk(model, {}, finishReason))
}
res.write('data: [DONE]\n\n')
res.end()
return
}
@@ -709,8 +751,10 @@ function streamScriptedTurn(
} else {
res.write(sseChunk(model, {}, finishReason))
}
res.write('data: [DONE]\n\n')
res.end()
return
}
@@ -733,9 +777,11 @@ function nonStreamingScriptedTurn(
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
const message: Record<string, unknown> = { role: 'assistant' }
if (turn.text) {
message.content = turn.text
}
if (hasToolCalls) {
message.tool_calls = turn.toolCalls!.map((tc, idx) => ({
id: `call_e2e_${_scriptIndex}_${idx}`,
@@ -804,6 +850,7 @@ export function createBackgroundReleaseHandle(): BackgroundReleaseHandle {
os.tmpdir(),
`hermes-e2e-bg-release-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
)
return {
path,
release: () => {
@@ -867,3 +914,136 @@ export const SIDEBAR_CROSS_TEXTS = {
/** The subagent's goal. */
subagentGoal: 'Analyze cross-session state',
} as const
// ─── Dev launcher ──────────────────────────────────────────────────────
//
// Running this file directly (`node tests-js/scripts/mock-server.ts`)
// starts the server, writes an isolated config.yaml + .env that point at
// it, and launches the built Electron app against them — the `dev:mock`
// flow. Importing the module never runs this block: the Playwright E2E
// suite imports the server as a library instead.
interface DevSandbox {
root: string
hermesHome: string
userDataDir: string
cleanup: () => void
}
/** Create an isolated HERMES_HOME + Electron user-data dir in the OS temp dir. */
function createDevSandbox(): DevSandbox {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), `hermes-dev-mock-${Date.now()}`))
const hermesHome = nodePath.join(root, 'hermes-home')
const userDataDir = nodePath.join(root, 'electron-user-data')
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(userDataDir, { recursive: true })
return {
root,
hermesHome,
userDataDir,
cleanup: () => {
try {
fs.rmSync(root, { recursive: true, force: true })
} catch {
// best-effort
}
},
}
}
/** Write a config.yaml + .env that pre-configure the mock provider. */
function writeMockConfig(hermesHome: string, mockUrl: string): void {
fs.writeFileSync(
nodePath.join(hermesHome, 'config.yaml'),
`# Auto-generated by dev-mock
model:
default: mock-model
provider: mock
providers:
mock:
api: ${mockUrl}/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`,
'utf8',
)
fs.writeFileSync(nodePath.join(hermesHome, '.env'), 'MOCK_API_KEY=e2e-mock-key\n', 'utf8')
}
/** Resolve the Electron binary: the repo's own install, then PATH. */
function findElectron(repoRoot: string): string {
const local = nodePath.join(repoRoot, 'node_modules', 'electron', 'dist', 'electron')
if (fs.existsSync(local)) {return local}
const r = spawnSync('which', ['electron'], { encoding: 'utf8' })
if (r.status === 0 && r.stdout.trim()) {return r.stdout.trim()}
throw new Error('Electron binary not found. Run "npm install" from the repo root.')
}
/** Fail fast with a clear message when the desktop dist/ is missing. */
function assertDistBuilt(desktopRoot: string): void {
const electronMain = nodePath.join(desktopRoot, 'dist', 'electron-main.mjs')
const indexHtml = nodePath.join(desktopRoot, 'dist', 'index.html')
if (!fs.existsSync(electronMain) || !fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
}
/** Start the mock, write the sandbox, and launch the built Electron app. */
async function runDevLaunch(): Promise<void> {
const desktopRoot = nodePath.resolve(import.meta.dirname, '..', '..', 'apps', 'desktop')
const repoRoot = nodePath.resolve(desktopRoot, '..', '..')
assertDistBuilt(desktopRoot)
console.log('Starting mock inference server...')
const mock = await startMockServer()
console.log(` Mock server: ${mock.url}`)
const sandbox = createDevSandbox()
writeMockConfig(sandbox.hermesHome, mock.url)
console.log(` HERMES_HOME: ${sandbox.hermesHome}`)
const electronBin = findElectron(repoRoot)
const env: Record<string, string> = {
...process.env,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: repoRoot,
HERMES_DESKTOP_APP_NAME: `HermesDevMock-${Date.now()}`,
}
console.log('Launching Electron...')
const child = spawn(electronBin, [desktopRoot, '--disable-gpu', '--no-sandbox'], {
env,
cwd: desktopRoot,
stdio: 'inherit',
})
child.on('exit', (code: number | null) => {
void mock.close()
sandbox.cleanup()
process.exit(code ?? 0)
})
}
// Only run the dev launcher when this file is executed directly.
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
runDevLaunch().catch((err: unknown) => {
console.error(err)
process.exit(1)
})
}