Files
hermes-desktop/tests/electron/desktop.spec.ts
T
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

123 lines
6.3 KiB
TypeScript

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<number>((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<void>(resolve => probe.listen(0, '127.0.0.1', resolve))
const port = (probe.address() as { port: number }).port
await new Promise<void>(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 })
}
})