/** * E2E: the fleet profile rail with two registered gateways. * * "This device" is the Electron-managed local backend (mock inference). The * second gateway, "Homelab", is a REAL second `hermes serve` this spec spawns * with its own HERMES_HOME, profiles and session token, registered in the v2 * connections.json as a remote URL connection. A click on an at-rest square * therefore performs the same dial → commit → re-home the statusbar switcher * does, against a real backend — not a stub. * * Prerequisite: `npm run build` must have been run so dist/ exists, and the * repo's Python venv (`.venv`) must exist for both backends. */ import { type ChildProcess, spawn, spawnSync } from 'node:child_process' import * as fs from 'node:fs' import * as net from 'node:net' import * as path from 'node:path' import { buildAppEnv, createSandbox, launchDesktop, type MockBackendFixture, type Sandbox, waitForAppReady, writeEnvFile, writeMockProviderConfig, } from './fixtures' import { startMockServer } from './mock-server' import { type ElectronApplication, expect, type Page, test } from './test' const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') const REMOTE_LABEL = 'Homelab' const REMOTE_ID = 'homelab' const REMOTE_TOKEN = 'e2e-fleet-homelab-token' interface RemoteGateway { url: string home: string close: () => Promise } function findHermesBinary(): string { const venv = path.join(REPO_ROOT, '.venv', 'bin', 'hermes') if (fs.existsSync(venv)) { return venv } const result = spawnSync('which', ['hermes'], { encoding: 'utf8' }) if (result.status === 0 && result.stdout.trim()) { return result.stdout.trim() } throw new Error('hermes binary not found: create the repo venv (uv sync) or put hermes on PATH') } async function freePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer() server.unref() server.on('error', reject) server.listen(0, '127.0.0.1', () => { const { port } = server.address() as net.AddressInfo server.close(() => resolve(port)) }) }) } /** Seed `/profiles//` so the backend's /api/profiles lists it. */ function seedProfiles(home: string, names: string[]): void { for (const name of names) { const dir = path.join(home, 'profiles', name) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(path.join(dir, 'config.yaml'), '', 'utf8') } } /** * Spawn a second, fully real `hermes serve` as the remote gateway. Its * session token is pinned through HERMES_DASHBOARD_SESSION_TOKEN so the * registry entry can carry a plaintext token envelope. */ async function startRemoteGateway(root: string, mockUrl: string, profiles: string[]): Promise { const home = path.join(root, 'homelab-home') fs.mkdirSync(home, { recursive: true }) writeMockProviderConfig(home, mockUrl) writeEnvFile(home) seedProfiles(home, profiles) const port = await freePort() const url = `http://127.0.0.1:${port}` const child: ChildProcess = spawn( findHermesBinary(), ['serve', '--host', '127.0.0.1', '--port', String(port), '--skip-build'], { cwd: REPO_ROOT, detached: true, env: { ...process.env, HERMES_HOME: home, HERMES_DASHBOARD_SESSION_TOKEN: REMOTE_TOKEN, }, stdio: ['ignore', 'pipe', 'pipe'], }, ) let log = '' child.stdout?.on('data', (chunk: Buffer) => { log += chunk.toString() }) child.stderr?.on('data', (chunk: Buffer) => { log += chunk.toString() }) const deadline = Date.now() + 90_000 while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`remote hermes serve exited early (${child.exitCode}):\n${log}`) } try { const response = await fetch(`${url}/api/status`, { headers: { 'X-Hermes-Session-Token': REMOTE_TOKEN }, }) if (response.ok) { break } } catch { // not up yet } await new Promise(resolve => setTimeout(resolve, 500)) } if (Date.now() >= deadline) { throw new Error(`remote hermes serve never became ready:\n${log}`) } return { url, home, close: async () => { if (child.pid && child.exitCode === null) { try { process.kill(-child.pid, 'SIGTERM') } catch { child.kill('SIGTERM') } } await new Promise(resolve => setTimeout(resolve, 500)) }, } } function writeConnectionsRegistry(sandbox: Sandbox, remoteUrl: string): void { fs.writeFileSync( path.join(sandbox.userDataDir, 'connections.json'), JSON.stringify( { version: 2, primary: 'local', launchMode: 'primary', lastUsed: 'local', connections: [ { id: 'local', kind: 'local', label: 'This device' }, { id: REMOTE_ID, kind: 'remote', label: REMOTE_LABEL, url: remoteUrl, authMode: 'token', token: { encoding: 'plain', value: REMOTE_TOKEN }, }, ], }, null, 2, ), { encoding: 'utf8', mode: 0o600 }, ) } // FLEET_RAIL_SCREENSHOT_DIR= saves full-window captures at the key // states — handy for design review; never part of the assertions. async function capture(page: Page, name: string): Promise { const dir = process.env.FLEET_RAIL_SCREENSHOT_DIR if (!dir) { return } fs.mkdirSync(dir, { recursive: true }) await page.screenshot({ path: path.join(dir, `${name}.png`) }) } const rail = (page: Page) => page.locator('[data-slot="profile-rail"]') const gatewayGroup = (page: Page, id: string) => rail(page).locator(`[data-slot="profile-rail-gateway"][data-connection-id="${id}"]`) const activeGatewayLabel = (page: Page) => page.getByRole('button', { name: /^Registered gateways: / }) async function groupOrder(page: Page): Promise> { return rail(page).locator('[data-slot="profile-rail-gateway"]').evaluateAll(nodes => nodes.map(node => [node.getAttribute('data-connection-id') ?? '', node.getAttribute('data-active') === 'true'] as [string, boolean]), ) } test.describe('fleet profile rail — two registered gateways', () => { test.describe.configure({ mode: 'serial' }) let mock: Awaited> let sandbox: Sandbox let remote: RemoteGateway let app: ElectronApplication let page: Page test.beforeAll(async () => { test.setTimeout(240_000) mock = await startMockServer() sandbox = createSandbox('fleet') writeMockProviderConfig(sandbox.hermesHome, mock.url) writeEnvFile(sandbox.hermesHome) // A named profile on This device too, so the active group has a square // beside its home pill. "research" exists on BOTH gateways on purpose: the // rail must keep the two apart by gateway, never by name alone. seedProfiles(sandbox.hermesHome, ['research']) remote = await startRemoteGateway(sandbox.root, mock.url, ['inbox', 'research']) writeConnectionsRegistry(sandbox, remote.url) ;({ app, page } = await launchDesktop(buildAppEnv(sandbox))) await waitForAppReady({ app, page } as MockBackendFixture, 120_000) // Let boot settle fully (the gateway health item reports "ready" once the // primary socket is open) so the boot-time launch-mode restore has run // before any click — the rail must then hold whatever the user picks. await expect(page.locator('[data-slot="statusbar"]').getByText('ready', { exact: true })).toBeVisible({ timeout: 120_000 }) await page.waitForTimeout(2_000) }) test.afterAll(async () => { await app?.close().catch(() => undefined) await remote?.close() await mock?.close() sandbox?.cleanup() }) test('lays both gateways on one strip, active gateway in its registry slot', async () => { // The statusbar readout names the gateway the workspace is on. await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 60_000 }) // The remote gateway's group appears once the roster has enumerated it. const homelab = gatewayGroup(page, REMOTE_ID) await expect(homelab).toBeVisible({ timeout: 60_000 }) await expect(homelab.getByRole('button', { name: `default · ${REMOTE_LABEL}` })).toBeVisible() await expect(homelab.getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible() await expect(homelab.getByRole('button', { name: `research · ${REMOTE_LABEL}` })).toBeVisible() await expect(homelab).toHaveAttribute('data-reachable', 'true') // Its marker carries the remote (network) glyph. await expect( rail(page).locator(`[data-slot="profile-rail-divider"][data-connection-id="${REMOTE_ID}"] [data-connection-kind="remote"]`), ).toBeVisible() // This device is the active group: its squares are unqualified, as before. const local = gatewayGroup(page, 'local') await expect(local).toHaveAttribute('data-active', 'true') await expect(local.getByRole('button', { name: 'research', exact: true })).toBeVisible() // Registry order: This device first, Homelab second. expect(await groupOrder(page)).toEqual([ ['local', true], [REMOTE_ID, false], ]) // Fleet pill replaces the default↔all toggle; the single-gateway plug is gone. await expect(rail(page).getByRole('button', { name: 'All profiles on this gateway' })).toBeVisible() await expect(rail(page).getByRole('button', { name: 'Manage gateways…' })).toHaveCount(0) await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).hover() await capture(page, '1-on-this-device-hover-inbox-homelab') }) test('clicking an at-rest square re-homes onto that exact gateway and profile', async () => { test.setTimeout(180_000) await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).click() // The workspace follows the agent: statusbar readout flips to Homelab… await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', `Registered gateways: ${REMOTE_LABEL}`, { timeout: 120_000 }) // …Homelab's group is now the active one, on the clicked profile… const homelab = gatewayGroup(page, REMOTE_ID) await expect(homelab).toHaveAttribute('data-active', 'true', { timeout: 30_000 }) await expect(homelab.getByRole('button', { name: 'inbox', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 }) // …This device is at rest with qualified squares… const local = gatewayGroup(page, 'local') await expect(local).toHaveAttribute('data-active', 'false') await expect(local.getByRole('button', { name: 'research · This device' })).toBeVisible() // …and nothing moved: the order is still This device, then Homelab. expect(await groupOrder(page)).toEqual([ ['local', false], [REMOTE_ID, true], ]) await capture(page, '2-re-homed-on-homelab-inbox') }) test('an at-rest square offers gateway-scoped actions, never the legacy remote override', async () => { const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' }) await square.click({ button: 'right' }) const menu = page.getByRole('menu', { name: 'Actions' }) await expect(menu).toBeVisible() await expect(menu.getByRole('menuitem', { name: 'Switch to research on This device' })).toBeVisible() await expect(menu.getByRole('menuitem', { name: 'Rename…' })).toBeVisible() await expect(menu.getByRole('menuitem', { name: 'Edit SOUL.md…' })).toBeVisible() await expect(menu.getByRole('menuitem', { name: 'Delete' })).toBeVisible() await expect(menu.getByRole('menuitem', { name: 'Connect to a remote host…' })).toHaveCount(0) await capture(page, '3-at-rest-square-context-menu') await page.keyboard.press('Escape') await expect(menu).toBeHidden() }) test('editing SOUL.md on an at-rest square reads the owning gateway, not the foreground one', async () => { const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' }) await square.click({ button: 'right' }) await page.getByRole('menu', { name: 'Actions' }).getByRole('menuitem', { name: 'Edit SOUL.md…' }).click() const dialog = page.getByRole('dialog') await expect(dialog).toBeVisible() await expect(dialog.getByText('research · This device · SOUL.md')).toBeVisible() await page.keyboard.press('Escape') await expect(dialog).toBeHidden() }) test('switching back lands on the clicked profile of This device and keeps the order', async () => { test.setTimeout(180_000) await gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' }).click() await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 120_000 }) const local = gatewayGroup(page, 'local') await expect(local).toHaveAttribute('data-active', 'true', { timeout: 30_000 }) await expect(local.getByRole('button', { name: 'research', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 }) await expect(gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible() expect(await groupOrder(page)).toEqual([ ['local', true], [REMOTE_ID, false], ]) }) })