From fd02bd86218bc58ff56e5d336bc06e26784c0c79 Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 12 Aug 2026 10:15:25 -0400 Subject: [PATCH] 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. --- apps/desktop/e2e/chat.spec.ts | 2 +- .../e2e/correction-session-switch.spec.ts | 2 +- apps/desktop/e2e/fixtures.ts | 2 +- .../e2e/hidden-history-messages.spec.ts | 2 +- .../e2e/image-attachment-resume.spec.ts | 2 +- apps/desktop/e2e/interim-messages.spec.ts | 2 +- apps/desktop/e2e/large-session-resume.spec.ts | 2 +- apps/desktop/e2e/queue-turn-boundary.spec.ts | 2 +- ...session-compression-and-queue-stop.spec.ts | 2 +- apps/desktop/e2e/sidebar-states.spec.ts | 2 +- apps/desktop/e2e/tile-unread-bug.spec.ts | 2 +- apps/desktop/e2e/warm-resume-jitter.spec.ts | 2 +- .../e2e/worktree-branch-status.spec.ts | 2 +- apps/desktop/package.json | 2 +- apps/desktop/scripts/dev-mock.mjs | 237 ------------------ apps/desktop/tsconfig.e2e.json | 2 +- .../e2e => tests-js/scripts}/mock-server.ts | 182 +++++++++++++- 17 files changed, 196 insertions(+), 253 deletions(-) delete mode 100644 apps/desktop/scripts/dev-mock.mjs rename {apps/desktop/e2e => tests-js/scripts}/mock-server.ts (86%) diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts index 9a55d9fc8e..3d79c315e4 100644 --- a/apps/desktop/e2e/chat.spec.ts +++ b/apps/desktop/e2e/chat.spec.ts @@ -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 diff --git a/apps/desktop/e2e/correction-session-switch.spec.ts b/apps/desktop/e2e/correction-session-switch.spec.ts index dc435b1d53..fc7e0359bc 100644 --- a/apps/desktop/e2e/correction-session-switch.spec.ts +++ b/apps/desktop/e2e/correction-session-switch.spec.ts @@ -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.` diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 787be42188..69ba0914e5 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -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, '..') diff --git a/apps/desktop/e2e/hidden-history-messages.spec.ts b/apps/desktop/e2e/hidden-history-messages.spec.ts index 23076f766e..77241d3367 100644 --- a/apps/desktop/e2e/hidden-history-messages.spec.ts +++ b/apps/desktop/e2e/hidden-history-messages.spec.ts @@ -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' diff --git a/apps/desktop/e2e/image-attachment-resume.spec.ts b/apps/desktop/e2e/image-attachment-resume.spec.ts index a4f8da68e3..4382e76035 100644 --- a/apps/desktop/e2e/image-attachment-resume.spec.ts +++ b/apps/desktop/e2e/image-attachment-resume.spec.ts @@ -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' diff --git a/apps/desktop/e2e/interim-messages.spec.ts b/apps/desktop/e2e/interim-messages.spec.ts index 2f6da01391..3837084985 100644 --- a/apps/desktop/e2e/interim-messages.spec.ts +++ b/apps/desktop/e2e/interim-messages.spec.ts @@ -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 ────────────────────────────────────────────────────────── diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts index 02c5f16937..4dfaaa7d92 100644 --- a/apps/desktop/e2e/large-session-resume.spec.ts +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -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, '..') diff --git a/apps/desktop/e2e/queue-turn-boundary.spec.ts b/apps/desktop/e2e/queue-turn-boundary.spec.ts index 2308d321a5..7c97115753 100644 --- a/apps/desktop/e2e/queue-turn-boundary.spec.ts +++ b/apps/desktop/e2e/queue-turn-boundary.spec.ts @@ -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' diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts index e48c52cda2..192fc8a45a 100644 --- a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -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 { const composer = page.locator('[contenteditable="true"]').first() diff --git a/apps/desktop/e2e/sidebar-states.spec.ts b/apps/desktop/e2e/sidebar-states.spec.ts index 6d8c0c2c9c..e760835caa 100644 --- a/apps/desktop/e2e/sidebar-states.spec.ts +++ b/apps/desktop/e2e/sidebar-states.spec.ts @@ -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' diff --git a/apps/desktop/e2e/tile-unread-bug.spec.ts b/apps/desktop/e2e/tile-unread-bug.spec.ts index fa614a21d2..e11782036f 100644 --- a/apps/desktop/e2e/tile-unread-bug.spec.ts +++ b/apps/desktop/e2e/tile-unread-bug.spec.ts @@ -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' diff --git a/apps/desktop/e2e/warm-resume-jitter.spec.ts b/apps/desktop/e2e/warm-resume-jitter.spec.ts index dbc0fe5dd1..b8e95c199e 100644 --- a/apps/desktop/e2e/warm-resume-jitter.spec.ts +++ b/apps/desktop/e2e/warm-resume-jitter.spec.ts @@ -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' diff --git a/apps/desktop/e2e/worktree-branch-status.spec.ts b/apps/desktop/e2e/worktree-branch-status.spec.ts index 971a1f6b41..66c821a7bc 100644 --- a/apps/desktop/e2e/worktree-branch-status.spec.ts +++ b/apps/desktop/e2e/worktree-branch-status.spec.ts @@ -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' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7e21884540..624afff1be 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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 .", diff --git a/apps/desktop/scripts/dev-mock.mjs b/apps/desktop/scripts/dev-mock.mjs deleted file mode 100644 index 7b523d88b3..0000000000 --- a/apps/desktop/scripts/dev-mock.mjs +++ /dev/null @@ -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) -}) diff --git a/apps/desktop/tsconfig.e2e.json b/apps/desktop/tsconfig.e2e.json index e0beb89619..5485ca4cc4 100644 --- a/apps/desktop/tsconfig.e2e.json +++ b/apps/desktop/tsconfig.e2e.json @@ -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"] } diff --git a/apps/desktop/e2e/mock-server.ts b/tests-js/scripts/mock-server.ts similarity index 86% rename from apps/desktop/e2e/mock-server.ts rename to tests-js/scripts/mock-server.ts index ce4665d177..61f40f403a 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/tests-js/scripts/mock-server.ts @@ -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 void) | null = null let releaseHeldStream: (() => void) | null = null let heldCompletionCount = 0 + const heldStreamStarted = new Promise(resolveHeld => { resolveHeldStreamStarted = resolveHeld }) + const heldStreamReleased = new Promise(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 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 { if (holdThisCompletion) { heldCompletionCount++ } + resolveHeldStreamStarted?.() + return heldStreamReleased } : undefined) } else { @@ -527,6 +560,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise { 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 = { 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 { + 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 = { + ...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) + }) +}