/** * Shared E2E fixtures for the Hermes desktop Playwright suite. * * Two fixture modes: * * 1. `mockBackend` — starts a mock inference server, writes a config.yaml * that points at it, and launches the desktop app so the full chain * (electron → hermes serve → provider → inference → renderer) is * exercised with a real backend but a fake LLM. * * 2. `noProvider` — launches the app with an empty config (no provider * configured). The onboarding overlay should appear. Used to test the * first-run flow without real credentials. * * Both modes launch the *dev* Electron app (`electron .` against the built * `dist/`), not the packaged binary. This avoids the multi-minute * `electron-builder --dir` step and matches `hermes desktop --source`. The * packaged-binary path is already covered by `launch.spec.ts`. * * Prerequisite: `npm run build` must have been run so that `dist/` exists. */ import { spawnSync } from 'node:child_process' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { _electron, type ElectronApplication, type Page } from '@playwright/test' import { startMockServer, type MockServerOptions } from './mock-server' import { installErrorBannerGuard } from './test' const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') // ─── Credential stripping (matches launch.spec.ts) ────────────────────── const CREDENTIAL_SUFFIXES: string[] = [ '_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_CREDENTIALS', '_ACCESS_KEY', '_PRIVATE_KEY', '_OAUTH_TOKEN', ] const CREDENTIAL_NAMES = new Set([ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_TOKEN', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'CUSTOM_API_KEY', 'GEMINI_BASE_URL', 'OPENAI_BASE_URL', 'OPENROUTER_BASE_URL', 'OLLAMA_BASE_URL', 'GROQ_BASE_URL', 'XAI_BASE_URL', ]) function isCredentialEnvVar(name: string): boolean { if (CREDENTIAL_NAMES.has(name)) { return true } return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix)) } function stripCredentials(env: Record): Record { const clean: Record = {} for (const [key, value] of Object.entries(env)) { if (!value) { continue } if (isCredentialEnvVar(key)) { continue } clean[key] = value } return clean } // ─── Sandbox creation ────────────────────────────────────────────────── export interface Sandbox { root: string hermesHome: string userDataDir: string cleanup: () => void } export function createSandbox(prefix: string): Sandbox { const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`)) 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 }) // Write a fixed window-state.json so the Electron window opens at a // consistent size — helps with visual regression screenshots. The // exact size is also enforced right before each screenshot (see // expectVisualSnapshot in visual-snapshot.ts) because window managers // may resize after launch. fs.writeFileSync( path.join(userDataDir, 'window-state.json'), JSON.stringify( { x: 0, y: 0, width: 1220, height: 800, isMaximized: false }, null, 2, ), 'utf8', ) return { root, hermesHome, userDataDir, cleanup: () => { try { fs.rmSync(root, { recursive: true, force: true }) } catch { // best-effort } }, } } // ─── Config writing ───────────────────────────────────────────────────── /** * Write a config.yaml that pre-configures a mock provider pointing at the * mock inference server. The provider is set as the active model provider so * the desktop app skips onboarding and boots straight to the chat UI. * * @param extraDisplayConfig optional YAML lines appended to the `display:` * section, used by the interim-message e2e test. * @param extraConfig optional top-level YAML sections for a test scenario. * @param modelContextLength optional primary-model context limit. */ export function writeMockProviderConfig( hermesHome: string, mockUrl: string, extraDisplayConfig?: string, extraConfig?: string, modelContextLength?: number, ): void { const configPath = path.join(hermesHome, 'config.yaml') const displaySection = extraDisplayConfig ? `\ndisplay:\n${extraDisplayConfig}\n` : '' const config = `# Auto-generated by E2E test fixtures model: default: mock-model provider: mock ${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers: mock: api: ${mockUrl}/v1 name: Mock api_mode: chat_completions key_env: MOCK_API_KEY models: mock-model: {} context_length: 4096 ${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}` fs.writeFileSync(configPath, config, 'utf8') } /** * Write a minimal .env with the mock API key. The key_env in config.yaml * references MOCK_API_KEY, so the backend resolves credentials from here. */ export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void { const envPath = path.join(hermesHome, '.env') fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8') } /** * Write an empty config (no providers). The desktop app should show the * onboarding overlay because no inference provider is configured. */ function writeEmptyConfig(hermesHome: string): void { const configPath = path.join(hermesHome, 'config.yaml') fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8') } // ─── Env building ────────────────────────────────────────────────────── /** * Build the environment for the Electron app process. * * Key env vars: * - HERMES_HOME → sandbox hermes-home (isolated config/sessions) * - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data * - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH * (we want the dev checkout at REPO_ROOT) * - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution) * - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock) * - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux */ export function buildAppEnv(sandbox: Sandbox, extra: Record = {}): Record { const clean = stripCredentials(process.env) // XDG_RUNTIME_DIR is needed for Electron on Linux when running in a // headless/CI context — without it the zygote may fail to initialize. if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) { clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR } // DISPLAY — needed for Electron to open a window. if (!clean.DISPLAY && process.env.DISPLAY) { clean.DISPLAY = process.env.DISPLAY } return { ...clean, 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: `HermesE2E-${Date.now()}`, // Clear dev-server override — we want the built dist/, not a vite server. // The dev-server check in main.ts looks for this env var; if it's set, // it loads from the vite URL instead of the local file. ...extra, } } // ─── Electron launch ──────────────────────────────────────────────────── /** * Verify that the desktop app has been built (dist/ exists). Playwright * tests can't run without it — the Electron main process loads * dist/electron-main.mjs and the renderer loads dist/index.html. */ function assertDistBuilt(): void { const distDir = path.join(DESKTOP_ROOT, 'dist') const electronMain = path.join(distDir, 'electron-main.mjs') const indexHtml = path.join(distDir, 'index.html') if (!fs.existsSync(electronMain)) { throw new Error( `Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` + `Missing: ${electronMain}`, ) } if (!fs.existsSync(indexHtml)) { throw new Error( `Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` + `Missing: ${indexHtml}`, ) } } /** * Find the Electron binary. In the nix devshell, `electron` is on PATH. * As a fallback, use the node_modules/.bin/electron from the desktop package. */ export function findElectron(): string { // In dev mode, we use the `electron` binary directly (not the packaged app). // The dev:electron script in package.json does exactly this: `electron .` // after building. We replicate that here. const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron') if (fs.existsSync(localElectron)) { return localElectron } // Fall back to PATH const result = spawnSync('which', ['electron'], { encoding: 'utf8', }) if (result.status === 0 && result.stdout.trim()) { return result.stdout.trim() } throw new Error( 'Electron binary not found. Run "npm install" from the repo root to install devDependencies.', ) } /** * Launch the desktop app in dev mode. * * @param sandbox - isolated HERMES_HOME + userData * @param env - the process environment (already has HERMES_HOME etc.) * @returns the ElectronApplication + first Page */ export async function launchDesktop( env: Record, ): Promise<{ app: ElectronApplication; page: Page }> { assertDistBuilt() const electronBin = findElectron() // `electron .` loads from the package.json `main` field // (dist/electron-main.mjs after build). const app = await _electron.launch({ executablePath: electronBin, args: [ DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir '--disable-gpu', '--no-sandbox', ], env, cwd: DESKTOP_ROOT, }) const page = await app.firstWindow() // Install the error-banner guard so any [role="alert"] that appears // during a test is collected and surfaced in afterEach. installErrorBannerGuard(page) return { app, page } } // ─── Public fixtures ──────────────────────────────────────────────────── export interface MockBackendFixture { app: ElectronApplication page: Page mock: Awaited> mockUrl: string sandbox: Sandbox cleanup: () => Promise } export interface MockBackendOptions { /** * Optional YAML lines to inject under the `display:` section of the * generated config.yaml. Used by the interim-message e2e test to toggle * `display.interim_assistant_messages`. */ extraDisplayConfig?: string /** Additional top-level config.yaml sections for an E2E scenario. */ extraConfig?: string /** Override the mock model's context window for compression scenarios. */ modelContextLength?: number } /** * Set up a full mock-backend E2E environment: * 1. Start the mock inference server * 2. Create a sandbox with config.yaml pointing at it * 3. Launch the desktop app * 4. Return handles for test interaction */ export interface MockBackendOptions { mockServer?: MockServerOptions } export async function setupMockBackend(options: MockBackendOptions = {}): Promise { // 1. Start mock server const mock = await startMockServer(options.mockServer) // 2. Create sandbox + write config const sandbox = createSandbox('mock') writeMockProviderConfig( sandbox.hermesHome, mock.url, options.extraDisplayConfig, options.extraConfig, options.modelContextLength, ) writeEnvFile(sandbox.hermesHome) // 3. Build env + launch const env = buildAppEnv(sandbox) const { app, page } = await launchDesktop(env) return { app, page, mock, mockUrl: mock.url, sandbox, cleanup: async () => { await app.close().catch(() => undefined) await mock.close() sandbox.cleanup() }, } } export interface NoProviderFixture { app: ElectronApplication page: Page sandbox: Sandbox cleanup: () => Promise } /** * Launch the app with no provider configured. The onboarding overlay should * appear because there's no inference provider in config.yaml. */ export async function setupNoProvider(): Promise { const sandbox = createSandbox('noprovider') writeEmptyConfig(sandbox.hermesHome) const env = buildAppEnv(sandbox) const { app, page } = await launchDesktop(env) return { app, page, sandbox, cleanup: async () => { await app.close().catch(() => undefined) sandbox.cleanup() }, } } export interface DeadBackendFixture { app: ElectronApplication page: Page sandbox: Sandbox cleanup: () => Promise } export interface DeadBackendOptions { /** * When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR * so the backend resolution itself "fails" with a controlled error message. * This is the only reliable way to trigger BootFailureOverlay in dev mode * (the real backend always resolves via SOURCE_REPO_ROOT). */ fakeError?: boolean } /** * Launch the app with a provider pointing at a dead endpoint (port 1, which * nothing listens on). By default the backend still boots (`hermes serve` * starts fine — the dead endpoint only matters at chat time). Pass * `{ fakeError: true }` to inject a fake boot failure, triggering the * BootFailureOverlay. */ export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise { const sandbox = createSandbox('dead') const configPath = path.join(sandbox.hermesHome, 'config.yaml') fs.writeFileSync( configPath, `# Auto-generated by E2E test fixtures — dead provider model: default: mock-model provider: mock providers: mock: api: http://127.0.0.1:1/v1 name: Mock api_mode: chat_completions key_env: MOCK_API_KEY models: mock-model: {} context_length: 4096 `, 'utf8', ) writeEnvFile(sandbox.hermesHome) const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {}) const { app, page } = await launchDesktop(env) return { app, page, sandbox, cleanup: async () => { await app.close().catch(() => undefined) sandbox.cleanup() }, } } // ─── Packaged-binary fixture ─────────────────────────────────────────── /** * Resolve the packaged Electron binary path, per-platform, matching * electron-builder's output layout under release/. */ function resolvePackagedBinaryPath(): string { if (process.platform === 'win32') { return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe') } if (process.platform === 'darwin') { const arch = process.arch === 'arm64' ? 'arm64' : 'x64' return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes') } return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes') } export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath() export function packagedBinaryExists(): boolean { return fs.existsSync(PACKAGED_BINARY_PATH) } export interface PackagedAppFixture { app: ElectronApplication page: Page sandbox: Sandbox cleanup: () => Promise } /** * Launch the *packaged* Electron binary (from `npm run pack` → * `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot * progress without spawning a real Hermes backend. * * Uses the same sandbox isolation (credential stripping, isolated * HERMES_HOME + userData, unique app name) as the dev-mode fixtures. * * Skips if the packaged binary doesn't exist — run `npm run pack` first. */ export async function setupPackagedApp(): Promise { if (!packagedBinaryExists()) { throw new Error( `Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`, ) } const sandbox = createSandbox('packaged') // Build the sandbox env using the shared helpers, then add the // packaged-binary-specific overrides. const env = buildAppEnv(sandbox, { // Fake boot: simulates progress steps without spawning the real backend. HERMES_DESKTOP_BOOT_FAKE: '1', HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120', }) // Clear dev-server + hermes-root overrides — the packaged binary // should use its own bundled renderer, not the dev checkout. delete (env as Record).HERMES_DESKTOP_DEV_SERVER delete (env as Record).HERMES_DESKTOP_HERMES delete (env as Record).HERMES_DESKTOP_HERMES_ROOT const app = await _electron.launch({ executablePath: PACKAGED_BINARY_PATH, args: ['--disable-gpu', '--no-sandbox'], env, }) const page = await app.firstWindow() installErrorBannerGuard(page) return { app, page, sandbox, cleanup: async () => { await app.close().catch(() => undefined) sandbox.cleanup() }, } } // ─── Wait helpers ────────────────────────────────────────────────────── /** * Wait for the desktop app to finish booting and show the main chat UI. * * The boot overlay disappears when `completeDesktopBoot()` fires in the * renderer — at that point the gateway is open, config is loaded, and * sessions are loaded. We detect this by waiting for the boot/connecting * overlay to become invisible and the main app shell to be present. * * Two things must both be true before we return: * 1. The composer (chat input) is visible — it's disabled until the * gateway is open. * 2. No full-screen overlay (onboarding Preparing, connecting overlay, * boot-failure) covers the viewport center. The composer can be * "visible" in Playwright's eyes (non-zero bounding box, not * display:none) even when a z-1300+ overlay is painted on top of it, * so checking the composer alone catches the app mid-boot at ~92% * with the loading bar still showing. */ export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise { const { page, app } = fixture // Wait for the composer to exist in the DOM (not necessarily interactive yet). await page.waitForSelector('textarea, [contenteditable="true"]', { state: 'attached', timeout: timeoutMs, }) // Now poll until no full-screen overlay covers the viewport center. // elementFromPoint returns the topmost element at a point — if it's part // of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the // app isn't ready yet. await page.waitForFunction( () => { const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2) if (!el) { return false } // Walk up to the nearest positioned ancestor — overlays are // `position: fixed; inset: 0`. If the hit element or an ancestor // is a full-viewport fixed overlay, we're still covered. let node: Element | null = el while (node) { const cs = window.getComputedStyle(node) if (cs.position === 'fixed') { const rect = node.getBoundingClientRect() if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) { return false } } node = node.parentElement } return true }, undefined, { timeout: timeoutMs }, ) // On Electron 40.x, ready-to-show may never fire (electron/electron#51972) // and the window stays hidden even though the DOM is rendered. The main // process has a TEST_WORKER_INDEX-gated fallback that force-shows the // window, but the DOM can be ready before that fires. Poll until the // window is actually visible so interactions (click, screenshot) don't // hit a hidden surface. if (app) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { const visible = await app.evaluate(({ BrowserWindow }) => { const w = BrowserWindow.getAllWindows()[0] return w ? w.isVisible() : false }).catch(() => false) if (visible) {break} await page.waitForTimeout(500) } } } /** * Wait for the onboarding overlay to appear (no provider configured). */ export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise { // The onboarding overlay contains a heading with "Choose your provider" // or similar text. We look for any text that indicates the picker. await page.waitForFunction( () => { const root = document.getElementById('root') if (!root) { return false } const text = root.textContent ?? '' return ( text.includes('provider') || text.includes('Provider') || text.includes('Choose') || text.includes('API key') || text.includes('Sign in') ) }, undefined, { timeout: timeoutMs }, ) } /** * Wait for the boot failure overlay to appear. */ export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise { await page.waitForFunction( () => { // Boot failure is terminal: the backend gave up. The renderer shows // either BootFailureOverlay (z-1400, with Retry/Repair buttons) or // falls back to the onboarding picker (z-1300) as a recovery path. // We wait for the failure dialog itself — the Preparing component may // still paint its progress bar (recolored red) underneath the overlay, // which is harmless. const text = document.body.textContent ?? '' // BootFailureOverlay buttons. const hasFailureUI = text.includes('Retry') || text.includes('Repair') || text.includes('Use local gateway') || text.includes('Connection settings') // The error toast / notification that fires on failDesktopBoot(). const hasErrorToast = text.includes('Desktop boot failed') return hasFailureUI || hasErrorToast }, undefined, { timeout: timeoutMs }, ) }