123 lines
6.3 KiB
TypeScript
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 })
|
|
}
|
|
})
|