fd565c80e9
With several gateways registered, the Sessions profile rail only ever showed the active gateway's profiles; reaching a bot on another machine meant a gateway switch first, then a click on the rail that appeared afterwards. Bot Mode (#91134) and Capabilities already read the union agent roster; the rail is now its third consumer. - Every registered gateway's profiles sit on the one strip, in registry order (This device first, then by label), each group headed by that gateway's kind glyph. The active gateway's squares are unchanged; the others are "at rest" (dimmed) with tooltips/accessible names qualified by machine (`inbox · Homelab`), so same-named profiles never read alike. - Clicking an at-rest square performs the same dial → commit → re-home as the statusbar switcher, landing on that exact (gateway, profile): `selectConnection(id, { profile })`. The spinner sits on the clicked square; the previous source stays painted until the target answers. Groups keep their slots whichever gateway is active, so a square never moves under the pointer that clicked it. - Right-click on an at-rest square: Switch to / Color / Rename / Edit SOUL.md / Delete, executed on the owning gateway (renameProfile, getProfileSoul and updateProfileSoul accept the same scope deleteProfile already had); the delete confirmation names the machine. The legacy per-profile "Connect to a remote host…" item is hidden on multi-gateway setups, where the rail shows machines directly. - Unreachable gateways keep their squares with an amber dot on the glyph; two registrations of one backend collapse to one group; past thirteen squares across the fleet the strip condenses into a menu sectioned by gateway. Roster is fetched on mount / focus / registry change only — no periodic fleet polling. - Single-gateway Desktops render exactly as before: no roster fetch, same DOM. Also fixes a boot race the e2e surfaced: initializeConnectionsRegistry() "restored" the launch-mode source over a switch the user had already made while boot was settling (same class as #91047). The restore now yields when a switch is pending or already landed. Tests: pure grouping (fleet-rail.test.ts), rail component fleet mode (profile-rail-fleet.test.tsx), store (explicit profile pick; restore yields), and a Playwright e2e (fleet-profile-rail.spec.ts) that boots Desktop with two REAL backends — the local one plus a second `hermes serve` registered as a remote URL connection — and verifies layout, a real re-home, gateway-scoped actions, and order stability. Docs: multi-connection-desktop.md describes the fleet rail. Refs #89304, #92384, #91047, #94724 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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],
|
|
])
|
|
})
|
|
})
|