增加一键重启所有网关按钮
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
2026-10-21 17:21:11 +08:00
parent 8fdec02a1c
commit 421ffa0714
12 changed files with 553 additions and 1 deletions
+26
View File
@@ -250,6 +250,32 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions =
return
}
if (pathname === '/api/hermes/profiles/gateways/restart-all') {
if (request.method() !== 'POST') {
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
return
}
await route.fulfill(jsonResponse({
success: true,
restarted: ['default', 'research'],
failed: [],
}))
return
}
if (pathname === '/api/hermes/profiles/restart-all') {
if (request.method() !== 'POST') {
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
return
}
await route.fulfill(jsonResponse({
success: true,
restarted: ['default', 'research'],
failed: [],
}))
return
}
if (pathname === '/api/hermes/profiles/active') {
if (request.method() !== 'PUT') {
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test'
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
test('profile manager modal fills its width and offers bulk restarts', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
const api = await mockHermesApi(page)
await mockChatSocket(page)
await page.goto('/#/hermes/models')
await page.locator('[data-testid="profile-selector-select"]').click()
const modal = page.locator('.profile-manager-modal')
await expect(modal).toBeVisible()
// The profile list must fill the modal body. Arco's .arco-spin is
// inline-block and collapses to its content width otherwise, which leaves a
// wide empty gutter next to the cards.
const widths = await page.evaluate(() => {
const list = document.querySelector('.profile-runtime-list') as HTMLElement
const body = document.querySelector('.profile-manager-modal .arco-modal-body') as HTMLElement
return {
list: Math.round(list.getBoundingClientRect().width),
bodyContent: Math.round(body.getBoundingClientRect().width - 40),
}
})
// The list scrolls, so it may be a scrollbar narrower than the body.
expect(widths.bodyContent - widths.list).toBeLessThanOrEqual(20)
expect(widths.list).toBeGreaterThan(widths.bodyContent * 0.9)
const restartGateways = page.getByRole('button', { name: 'Restart All Gateways' })
const restartProfiles = page.getByRole('button', { name: 'Restart All Profiles' })
await expect(restartGateways).toBeVisible()
await expect(restartProfiles).toBeVisible()
await restartGateways.click()
await restartProfiles.click()
const gatewayCalls = api.requests.filter((request) => request.pathname === '/api/hermes/profiles/gateways/restart-all')
const profileCalls = api.requests.filter((request) => request.pathname === '/api/hermes/profiles/restart-all')
expect(gatewayCalls).toHaveLength(1)
expect(gatewayCalls[0]?.method).toBe('POST')
expect(profileCalls).toHaveLength(1)
expect(profileCalls[0]?.method).toBe('POST')
expect(api.unexpectedRequests).toEqual([])
})
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest'
import {
resolveGatewayRestartTargets,
restartGatewaysForProfiles,
} from '../../packages/server/src/services/hermes/gateway-autostart'
describe('batch gateway restart', () => {
it('maps each profile to its own gateway target in per-profile mode', () => {
expect(resolveGatewayRestartTargets(['default', 'work'], false)).toEqual([
{ targetProfile: 'default', requestedProfile: 'default' },
{ targetProfile: 'work', requestedProfile: 'work' },
])
})
it('restarts a shared gateway once in unified mode', () => {
expect(resolveGatewayRestartTargets(['default', 'work', 'reviewer'], true)).toEqual([
{ targetProfile: 'default', requestedProfile: 'default' },
])
})
it('keeps the first requested profile for a duplicated target', () => {
expect(resolveGatewayRestartTargets(['work', 'default'], true)).toEqual([
{ targetProfile: 'default', requestedProfile: 'work' },
])
})
it('falls back to the default profile for a blank name', () => {
expect(resolveGatewayRestartTargets([''], false)).toEqual([
{ targetProfile: 'default', requestedProfile: 'default' },
])
})
it('stops, starts and waits for every restarted gateway', async () => {
const stopGateway = vi.fn().mockResolvedValue(undefined)
const startGateway = vi.fn().mockResolvedValue(undefined)
const waitForGateway = vi.fn().mockResolvedValue(true)
const results = await restartGatewaysForProfiles(['default', 'work'], {
unified: false,
stopGateway,
startGateway,
waitForGateway,
})
expect(results).toEqual([
{ profile: 'default', targetProfile: 'default', running: true },
{ profile: 'work', targetProfile: 'work', running: true },
])
expect(stopGateway).toHaveBeenCalledTimes(2)
expect(startGateway).toHaveBeenCalledTimes(2)
expect(waitForGateway).toHaveBeenCalledTimes(2)
})
it('restarts a shared gateway only once in unified mode', async () => {
const stopGateway = vi.fn().mockResolvedValue(undefined)
const startGateway = vi.fn().mockResolvedValue(undefined)
const waitForGateway = vi.fn().mockResolvedValue(true)
const results = await restartGatewaysForProfiles(['default', 'work'], {
unified: true,
stopGateway,
startGateway,
waitForGateway,
})
expect(results).toEqual([
{ profile: 'default', targetProfile: 'default', running: true },
])
expect(stopGateway).toHaveBeenCalledTimes(1)
expect(startGateway).toHaveBeenCalledTimes(1)
})
it('keeps going when one gateway fails and reports the error', async () => {
const stopGateway = vi.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('stop failed'))
const startGateway = vi.fn().mockResolvedValue(undefined)
const waitForGateway = vi.fn().mockResolvedValue(true)
const results = await restartGatewaysForProfiles(['default', 'work'], {
unified: false,
stopGateway,
startGateway,
waitForGateway,
})
expect(results).toEqual([
{ profile: 'default', targetProfile: 'default', running: true },
{ profile: 'work', targetProfile: 'work', running: false, error: 'stop failed' },
])
})
it('reports a gateway that never reports running as failed', async () => {
const stopGateway = vi.fn().mockResolvedValue(undefined)
const startGateway = vi.fn().mockResolvedValue(undefined)
const waitForGateway = vi.fn().mockResolvedValue(false)
const results = await restartGatewaysForProfiles(['default'], {
unified: false,
stopGateway,
startGateway,
waitForGateway,
})
expect(results[0]?.running).toBe(false)
expect(results[0]?.error).toContain('did not report running')
})
})
+65
View File
@@ -0,0 +1,65 @@
import Koa from 'koa'
import bodyParser from '@koa/bodyparser'
import { createServer, type Server as HttpServer } from 'http'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const controllerMocks = vi.hoisted(() => ({
restartAllGateways: vi.fn(),
restartAllProfiles: vi.fn(),
}))
vi.mock('../../packages/server/src/controllers/hermes/profiles', async (importOriginal) => ({
...(await importOriginal() as any),
restartAllGateways: controllerMocks.restartAllGateways,
restartAllProfiles: controllerMocks.restartAllProfiles,
}))
import { profileRoutes } from '../../packages/server/src/routes/hermes/profiles'
function listen(server: HttpServer): Promise<string> {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (!addr || typeof addr === 'string') throw new Error('missing address')
resolve(`http://127.0.0.1:${addr.port}`)
}))
}
describe('profile bulk restart routes', () => {
let httpServer: HttpServer
let baseUrl: string
beforeEach(async () => {
vi.clearAllMocks()
controllerMocks.restartAllGateways.mockImplementation((ctx: any) => {
ctx.body = { success: true }
})
controllerMocks.restartAllProfiles.mockImplementation((ctx: any) => {
ctx.body = { success: true }
})
const app = new Koa()
app.use(bodyParser())
app.use(profileRoutes.routes())
app.use(profileRoutes.allowedMethods())
httpServer = createServer(app.callback())
baseUrl = await listen(httpServer)
})
afterEach(async () => {
await new Promise<void>((resolve) => httpServer.close(() => resolve()))
})
it('routes the gateway bulk restart to its own handler', async () => {
const res = await fetch(`${baseUrl}/api/hermes/profiles/gateways/restart-all`, { method: 'POST' })
expect(res.status).toBe(200)
expect(controllerMocks.restartAllGateways).toHaveBeenCalledTimes(1)
expect(controllerMocks.restartAllProfiles).not.toHaveBeenCalled()
})
it('routes the profile bulk restart to its own handler', async () => {
const res = await fetch(`${baseUrl}/api/hermes/profiles/restart-all`, { method: 'POST' })
expect(res.status).toBe(200)
expect(controllerMocks.restartAllProfiles).toHaveBeenCalledTimes(1)
expect(controllerMocks.restartAllGateways).not.toHaveBeenCalled()
})
})