mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-23 22:54:56 -04:00
The node detail redesign moved backend operations into an action menu. Four existing specs still search for the removed direct buttons, so the UI E2E workflow fails consistently on master. Open the backend action menu before checking or activating its items. Assisted-by: Codex:gpt-5 Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
184 lines
5.9 KiB
JavaScript
184 lines
5.9 KiB
JavaScript
import { test, expect } from './coverage-fixtures.js'
|
|
|
|
// These specs cover the per-node backend row in the Nodes page:
|
|
// - the upgrade affordance is self-explanatory (icon + tooltip)
|
|
// - a delete affordance is present and goes through ConfirmDialog
|
|
//
|
|
// We mock the distributed-mode API so the tests can run against the
|
|
// standalone ui-test-server without spinning up workers/NATS.
|
|
|
|
const NODE_ID = 'test-node-1'
|
|
const NODE_NAME = 'worker-test'
|
|
const BACKEND_NAME = 'cuda12-vllm-development'
|
|
|
|
async function mockDistributedNodes(page, { onDelete } = {}) {
|
|
const nodeRecord = {
|
|
id: NODE_ID,
|
|
name: NODE_NAME,
|
|
node_type: 'backend',
|
|
address: '10.0.0.1:50051',
|
|
http_address: '10.0.0.1:8090',
|
|
status: 'healthy',
|
|
total_vram: 0,
|
|
available_vram: 0,
|
|
total_ram: 8_000_000_000,
|
|
available_ram: 4_000_000_000,
|
|
gpu_vendor: '',
|
|
last_heartbeat: new Date().toISOString(),
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
}
|
|
|
|
await page.route('**/api/nodes', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([nodeRecord]),
|
|
})
|
|
})
|
|
|
|
// The detail page fetches the single node via nodesApi.get(id).
|
|
await page.route(`**/api/nodes/${NODE_ID}`, (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(nodeRecord),
|
|
})
|
|
})
|
|
|
|
await page.route('**/api/nodes/scheduling', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: '[]',
|
|
})
|
|
})
|
|
|
|
await page.route(`**/api/nodes/${NODE_ID}/models`, (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: '[]',
|
|
})
|
|
})
|
|
|
|
await page.route(`**/api/nodes/${NODE_ID}/backends`, (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([
|
|
{
|
|
name: BACKEND_NAME,
|
|
is_system: false,
|
|
is_meta: false,
|
|
installed_at: new Date().toISOString(),
|
|
},
|
|
]),
|
|
})
|
|
})
|
|
|
|
await page.route(`**/api/nodes/${NODE_ID}/backends/delete`, async (route) => {
|
|
if (onDelete) {
|
|
await onDelete(route)
|
|
}
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ message: 'backend deleted' }),
|
|
})
|
|
})
|
|
}
|
|
|
|
async function openNodeDetail(page) {
|
|
// The per-node backend table now lives on the deep-linkable detail page
|
|
// at /app/nodes/:id (the old expand-row + "Manage" disclosure was removed
|
|
// when the roster was restructured). Navigate straight there.
|
|
await page.goto(`/app/nodes/${NODE_ID}`)
|
|
await expect(page.getByRole('cell', { name: BACKEND_NAME, exact: true })).toBeVisible({ timeout: 10_000 })
|
|
}
|
|
|
|
async function openBackendActions(page) {
|
|
const trigger = page.getByRole('button', { name: `Actions for backend ${BACKEND_NAME}` })
|
|
await expect(trigger).toBeVisible()
|
|
await trigger.click()
|
|
|
|
const menu = page.getByRole('menu', { name: `${BACKEND_NAME} backend actions` })
|
|
await expect(menu).toBeVisible()
|
|
return menu
|
|
}
|
|
|
|
test.describe('Nodes page — per-node backend actions', () => {
|
|
test('upgrade affordance is self-explanatory (not "Reinstall backend" with a sync icon)', async ({ page }) => {
|
|
await mockDistributedNodes(page)
|
|
await openNodeDetail(page)
|
|
|
|
await expect(page.locator('.node-detail__metrics')).toContainText('RAM')
|
|
await expect(page.locator('.node-detail__metrics')).toContainText('3.7 GB / 7.5 GB')
|
|
|
|
const menu = await openBackendActions(page)
|
|
|
|
// Negative: the old, ambiguous wording must not be used.
|
|
await expect(menu.getByRole('menuitem', { name: 'Reinstall backend' })).toHaveCount(0)
|
|
|
|
// Positive: the action menu names the operation and uses an upgrade icon.
|
|
const upgradeItem = menu.getByRole('menuitem', { name: 'Upgrade backend' })
|
|
await expect(upgradeItem).toBeVisible()
|
|
await expect(upgradeItem.locator('i.fa-arrow-up')).toBeVisible()
|
|
})
|
|
|
|
test('per-node backend row shows a delete (trash) button next to upgrade', async ({ page }) => {
|
|
await mockDistributedNodes(page)
|
|
await openNodeDetail(page)
|
|
|
|
const menu = await openBackendActions(page)
|
|
const deleteItem = menu.getByRole('menuitem', { name: 'Delete backend…' })
|
|
await expect(deleteItem).toBeVisible()
|
|
await expect(deleteItem.locator('i.fa-trash')).toBeVisible()
|
|
})
|
|
|
|
test('clicking delete opens the confirm dialog and POSTs to the per-node delete endpoint', async ({ page }) => {
|
|
let postedBody = null
|
|
await mockDistributedNodes(page, {
|
|
onDelete: async (route) => {
|
|
postedBody = route.request().postDataJSON()
|
|
},
|
|
})
|
|
await openNodeDetail(page)
|
|
|
|
const menu = await openBackendActions(page)
|
|
await menu.getByRole('menuitem', { name: 'Delete backend…' }).click()
|
|
|
|
// ConfirmDialog uses role="alertdialog" and a danger confirm button.
|
|
const dialog = page.getByRole('alertdialog')
|
|
await expect(dialog).toBeVisible()
|
|
const confirmBtn = dialog.locator('button.btn-danger')
|
|
await expect(confirmBtn).toBeVisible()
|
|
await confirmBtn.click()
|
|
|
|
// Wait until the POST landed.
|
|
await expect.poll(() => postedBody, { timeout: 5_000 }).toEqual({ backend: BACKEND_NAME })
|
|
})
|
|
|
|
test('clicking delete and cancelling does not POST', async ({ page }) => {
|
|
let deleteCalls = 0
|
|
await mockDistributedNodes(page, {
|
|
onDelete: () => {
|
|
deleteCalls += 1
|
|
},
|
|
})
|
|
await openNodeDetail(page)
|
|
|
|
const menu = await openBackendActions(page)
|
|
await menu.getByRole('menuitem', { name: 'Delete backend…' }).click()
|
|
|
|
const dialog = page.getByRole('alertdialog')
|
|
await expect(dialog).toBeVisible()
|
|
await dialog.getByRole('button', { name: /cancel/i }).click()
|
|
await expect(dialog).toBeHidden()
|
|
|
|
// Give any errant request a moment to fire so a regression would be caught.
|
|
await page.waitForTimeout(500)
|
|
expect(deleteCalls).toBe(0)
|
|
})
|
|
})
|