361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
/**
|
|
* 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<void>
|
|
}
|
|
|
|
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<number> {
|
|
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 `<home>/profiles/<name>/` 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<RemoteGateway> {
|
|
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=<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<void> {
|
|
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<Array<[string, boolean]>> {
|
|
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<ReturnType<typeof startMockServer>>
|
|
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],
|
|
])
|
|
})
|
|
})
|