import { _electron as electron, expect, test } from '@playwright/test' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { createServer } from 'node:net' function launchDesktop(home: string, development = false, frontendPort = 8649) { const executablePath = process.env.HERMES_ELECTRON_EXECUTABLE return electron.launch({ ...(executablePath ? { executablePath, args: [] } : { args: [resolve('.'), ...(development ? ['--dev'] : [])] }), env: { ...process.env, HERMES_ELECTRON_USER_DATA: join(home, 'chromium'), HERMES_WEB_UI_HOME: join(home, 'state'), HERMES_HOME: join(home, 'agent'), HERMES_BIN: join(home, 'missing-hermes'), HERMES_AGENT_BRIDGE_PYTHON: join(home, 'missing-python'), HERMES_AGENT_BRIDGE_ENDPOINT: '', HERMES_WEB_UI_FRONTEND_PORT: String(frontendPort), HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART: '1', HERMES_WEB_UI_DISABLE_SKILL_INJECTION: '1', HERMES_WEB_UI_DISABLE_MCP_AUTOINJECT: '1', }, timeout: 90_000, }) } test('starts the bundled local service, isolates the renderer and exits cleanly', async () => { const home = await mkdtemp(join(tmpdir(), 'hermes-electron-')) const desktop = await launchDesktop(home) try { const page = await desktop.firstWindow() await expect(page.locator('#app')).not.toBeEmpty() const url = new URL(page.url()) expect(url.hostname).toBe('127.0.0.1') expect(await page.evaluate(() => ({ node: typeof (window as any).require, process: typeof (window as any).process, desktop: (window as any).hermesDesktop, }))).toMatchObject({ node: 'undefined', process: 'undefined', desktop: { electron: expect.any(String) } }) const preferences = await desktop.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]!.webContents.getLastWebPreferences()) expect(preferences).toMatchObject({ sandbox: true, nodeIntegration: false, contextIsolation: true }) expect((await fetch(`${url.origin}/health`)).status).toBe(200) expect((await fetch(`${url.origin}/health`, { headers: { Origin: 'https://attacker.test' } })).status).toBe(403) const login = await fetch(`${url.origin}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: '123456' }), }) expect(login.ok).toBe(true) const { token } = await login.json() as { token: string } expect((await fetch(`${url.origin}/api/hermes/update`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } })).status).toBe(409) expect((await fetch(`${url.origin}/api/hermes/update/preview`, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(409) const shellPid = await page.evaluate(token => new Promise((resolve, reject) => { const ws = new WebSocket(`${location.origin.replace('http:', 'ws:')}/api/hermes/terminal?token=${encodeURIComponent(token)}`) const timeout = setTimeout(() => reject(new Error('Native terminal did not produce output')), 15_000) let pid = 0 ws.onerror = () => reject(new Error('Terminal WebSocket failed')) ws.onmessage = ({ data }) => { try { const event = JSON.parse(data) if (event.type === 'error') { clearTimeout(timeout); reject(new Error(event.message)); return } if (event.type === 'created') { pid = event.pid ws.send(JSON.stringify({ type: 'input', data: 'echo HERMES_NATIVE_TERMINAL_OK\r' })) } } catch { /* PTY output is plain text. */ } if (pid && String(data).includes('HERMES_NATIVE_TERMINAL_OK')) { clearTimeout(timeout); resolve(pid) } } }), token) const pid = await desktop.evaluate(({ app }) => app.getAppMetrics().find(metric => metric.name === 'Hermes local service')?.pid) expect(pid).toBeTruthy() const state = JSON.parse(await readFile(join(home, 'chromium/desktop.json'), 'utf8')) expect(state.port).toBe(Number(url.port)) await desktop.close() await expect.poll(async () => { try { process.kill(pid!, 0); return true } catch { return false } }).toBe(false) await expect.poll(async () => { try { process.kill(shellPid, 0); return true } catch { return false } }).toBe(false) await expect.poll(async () => { try { await fetch(`${url.origin}/health`); return true } catch { return false } }).toBe(false) } finally { await desktop.close().catch(() => {}) await rm(home, { recursive: true, force: true }) } }) test('reuses the local origin and renderer storage after restart', async () => { const home = await mkdtemp(join(tmpdir(), 'hermes-electron-restart-')) let desktop = await launchDesktop(home) try { const first = await desktop.firstWindow() await expect(first.locator('#app')).not.toBeEmpty() const firstOrigin = new URL(first.url()).origin await first.evaluate(() => localStorage.setItem('desktop-smoke-persist', 'saved')) await desktop.close() desktop = await launchDesktop(home) const second = await desktop.firstWindow() await expect(second.locator('#app')).not.toBeEmpty() expect(new URL(second.url()).origin).toBe(firstOrigin) expect(await second.evaluate(() => localStorage.getItem('desktop-smoke-persist'))).toBe('saved') } finally { await desktop.close().catch(() => {}) await rm(home, { recursive: true, force: true }) } }) test('runs the Vite renderer and API proxy inside Electron development', async () => { test.skip(!!process.env.HERMES_ELECTRON_EXECUTABLE, 'Development uses the source checkout') const home = await mkdtemp(join(tmpdir(), 'hermes-electron-dev-')) const probe = createServer() await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)) const port = (probe.address() as { port: number }).port await new Promise(resolve => probe.close(() => resolve())) const desktop = await launchDesktop(home, true, port) try { const page = await desktop.firstWindow() await expect(page.locator('#app')).not.toBeEmpty() expect(new URL(page.url()).port).toBe(String(port)) expect((await fetch(`http://127.0.0.1:${port}/@vite/client`)).status).toBe(200) expect((await fetch(`http://127.0.0.1:${port}/health`)).status).toBe(200) } finally { await desktop.close().catch(() => {}) await rm(home, { recursive: true, force: true }) } })