Files
LocalAI/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js
Richard Palethorpe 8d70855ea6 test: add Go + React UI coverage gates and fill test gaps (#9989)
- Strict monotonic Go coverage gate (make test-coverage-check, 45% baseline)
  run in CI; fixes ginkgo dropping all-but-one coverprofile across multiple
  recursive roots, builds with -tags auth, and folds in the in-process
  tests/e2e suite via --coverpkg.
- React UI e2e coverage (make test-ui-coverage: vite-plugin-istanbul + nyc,
  nix-provided Chromium) plus e2e specs for 6 previously-untested pages, and a
  UI coverage gate (make test-ui-coverage-check) with a small tolerance since
  e2e line coverage jitters ~0.5pp run-to-run.
- pre-commit hook: lint + coverage on Go changes, Playwright e2e + UI coverage
  gate on react-ui changes; install with make install-hooks.
- New Go handler tests (settings, branding), hermetic base64 download test.
- fix(ui): model editor reads vram_display (snake_case), so the VRAM estimate
  renders again; covered by a regression test.

Assisted-by: Claude:claude-opus-4-7

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-05-26 22:06:10 +02:00

167 lines
5.8 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 } = {}) {
await page.route('**/api/nodes', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
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/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 expandNodeAndWaitForBackends(page) {
await page.goto('/app/nodes')
// Click the row to expand it. The chevron toggle and the row both work,
// but clicking the name cell is the most user-like.
await page.getByText(NODE_NAME).first().click()
// Backends, Capacity and Labels live behind a "Manage" <details>
// disclosure (the drawer was distilled to keep at-a-glance content
// lean — see distill refactor in the multi-replica branch). Open it
// by clicking the summary inside the .node-manage scope so the
// per-node backend table is in the DOM before assertions run.
await page.locator('.node-manage > summary').first().click()
await expect(page.getByRole('cell', { name: BACKEND_NAME, exact: true })).toBeVisible({ timeout: 10_000 })
}
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 expandNodeAndWaitForBackends(page)
// Negative: the old, ambiguous wording must not be used.
await expect(page.locator('button[title="Reinstall backend"]')).toHaveCount(0)
await expect(page.locator('button[title="Reinstall backend"] i.fa-sync-alt')).toHaveCount(0)
// Positive: a self-explanatory upgrade affordance is rendered next to the
// backend row. We accept either an arrow-up or arrows-rotate glyph; both
// map to "upgrade" semantics in FontAwesome 6 unambiguously.
const upgradeBtn = page.locator('button[title="Upgrade backend on this node"]')
await expect(upgradeBtn).toBeVisible()
const iconClass = await upgradeBtn.locator('i').getAttribute('class')
expect(iconClass).toMatch(/fa-(arrow-up|arrows-rotate|up-long)/)
})
test('per-node backend row shows a delete (trash) button next to upgrade', async ({ page }) => {
await mockDistributedNodes(page)
await expandNodeAndWaitForBackends(page)
const deleteBtn = page.locator('button[title="Delete backend from this node"]')
await expect(deleteBtn).toBeVisible()
await expect(deleteBtn.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 expandNodeAndWaitForBackends(page)
await page.locator('button[title="Delete backend from this node"]').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 expandNodeAndWaitForBackends(page)
await page.locator('button[title="Delete backend from this node"]').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)
})
})