mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
The spec expected the register address to carry port 8089, so it failed on any other port. It now expects the origin the page was opened from. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
168 lines
9.6 KiB
JavaScript
168 lines
9.6 KiB
JavaScript
import { test, expect } from './coverage-fixtures.js'
|
|
import { mockSwarm, clusterNodes } from './swarm-fixtures.js'
|
|
|
|
// "Add a node": which way the machine joins, the one command to run on it, and
|
|
// a live line that says when it arrived. "Found" is a real node that was not in
|
|
// the roster when the page opened, never a timer.
|
|
|
|
const command = page => page.getByTestId('command-block').last()
|
|
|
|
test.describe('Add a node', () => {
|
|
test('starts on a registered worker with a copyable command that carries no secret', async ({ page }) => {
|
|
await mockSwarm(page)
|
|
await page.goto('/app/nodes/add')
|
|
await expect(page.getByRole('heading', { name: 'Add a node' })).toBeVisible({ timeout: 15_000 })
|
|
await expect(page.getByRole('radio', { name: 'Registered worker' })).toHaveAttribute('aria-checked', 'true')
|
|
await expect(command(page)).toContainText(`LOCALAI_REGISTER_TO="${new URL(page.url()).origin}"`)
|
|
await expect(command(page)).toContainText('LOCALAI_REGISTRATION_TOKEN="$TOKEN"')
|
|
await expect(command(page)).toContainText('localai/localai:latest-cpu worker')
|
|
await expect(page.getByText('This page never shows it.')).toBeVisible()
|
|
await expect(page.getByRole('link', { name: 'All nodes' })).toHaveAttribute('href', '/app/nodes')
|
|
// A cluster needs no step for turning distributed mode on.
|
|
await expect(page.getByText('Turn on distributed mode first')).toHaveCount(0)
|
|
})
|
|
|
|
test('changes the command with the hardware, the way to run it and the worker type', async ({ page }) => {
|
|
await mockSwarm(page)
|
|
await page.goto('/app/nodes/add')
|
|
await page.getByRole('radio', { name: 'CUDA 12', exact: true }).click()
|
|
await expect(command(page)).toContainText('--gpus all')
|
|
await expect(command(page)).toContainText('latest-gpu-nvidia-cuda-12 worker')
|
|
await page.getByRole('radio', { name: 'Agent worker' }).click()
|
|
await expect(command(page)).toContainText('latest-gpu-nvidia-cuda-12 agent-worker')
|
|
await page.getByRole('button', { name: 'Dev' }).click()
|
|
await expect(command(page)).toContainText('master-gpu-nvidia-cuda-12 agent-worker')
|
|
await page.getByRole('radio', { name: 'CLI' }).click()
|
|
await expect(command(page)).toContainText('local-ai agent-worker')
|
|
await expect(command(page)).toContainText('--registration-token "$LOCALAI_REGISTRATION_TOKEN"')
|
|
// Hardware means nothing to a plain command.
|
|
await expect(page.getByRole('radio', { name: 'CUDA 12', exact: true })).toHaveCount(0)
|
|
})
|
|
|
|
test('copies the command and says so', async ({ page, context }) => {
|
|
await context.grantPermissions(['clipboard-read', 'clipboard-write']).catch(() => {})
|
|
await mockSwarm(page)
|
|
await page.goto('/app/nodes/add')
|
|
const copy = page.getByRole('button', { name: 'Copy command' }).last()
|
|
await copy.click()
|
|
await expect(copy).toContainText('Copied')
|
|
const text = await command(page).locator('code').innerText()
|
|
expect(text).toContain('LOCALAI_REGISTER_TO')
|
|
})
|
|
|
|
test('waits for a worker, then shows it when one registers, and approves it', async ({ page }) => {
|
|
// The shell and the page both read /api/nodes, and which of them asks first
|
|
// depends on load, so a call count cannot say when the worker registers. The
|
|
// worker joins when the test says so, after the page shows it is listening.
|
|
let registered = false
|
|
const known = clusterNodes()
|
|
const arrived = { id: 'n-new', name: 'gpu-box-9', node_type: 'backend', address: '10.0.4.19:50051', status: 'pending', version: 'v3.9.0', labels: {}, last_heartbeat: new Date().toISOString() }
|
|
const log = await mockSwarm(page, { onNodes: () => (registered ? [...known, arrived] : known) })
|
|
await page.goto('/app/nodes/add')
|
|
|
|
const wait = page.getByRole('status').filter({ hasText: 'Listening for a worker' })
|
|
await expect(wait).toBeVisible({ timeout: 15_000 })
|
|
await expect(page.getByTestId('found-node')).toHaveCount(0)
|
|
registered = true
|
|
|
|
const found = page.getByTestId('found-node')
|
|
await expect(found).toBeVisible({ timeout: 20_000 })
|
|
await expect(found).toContainText('gpu-box-9')
|
|
await expect(found).toContainText('Waiting for approval')
|
|
await expect(page.getByRole('heading', { name: 'A machine arrived' })).toBeVisible()
|
|
await expect(wait).toHaveCount(0)
|
|
await expect(found.getByRole('link', { name: 'Open node' })).toHaveAttribute('href', '/app/nodes/n-new')
|
|
|
|
await found.getByRole('button', { name: 'Approve' }).click()
|
|
await expect.poll(() => log.filter(entry => entry.path === '/api/nodes/n-new/approve').length).toBe(1)
|
|
await expect(page.getByText('gpu-box-9 approved')).toBeVisible()
|
|
})
|
|
|
|
test('does not treat the nodes it already knew as arrivals', async ({ page }) => {
|
|
await mockSwarm(page)
|
|
await page.goto('/app/nodes/add')
|
|
await expect(page.getByRole('status').filter({ hasText: 'Listening for a worker' })).toBeVisible({ timeout: 15_000 })
|
|
await page.waitForTimeout(3500)
|
|
await expect(page.getByTestId('found-node')).toHaveCount(0)
|
|
})
|
|
|
|
test('a single install starts with turning distributed mode on, as text it can copy', async ({ page }) => {
|
|
await mockSwarm(page, { nodes: null, features: { distributed: false } })
|
|
await page.goto('/app/nodes/add')
|
|
await expect(page.getByRole('heading', { name: 'Turn on distributed mode first' })).toBeVisible({ timeout: 15_000 })
|
|
await expect(page.getByTestId('command-block').first()).toContainText('local-ai run --distributed')
|
|
await expect(page.getByTestId('command-block').first()).toContainText('--distributed-nats')
|
|
await expect(page.getByRole('link', { name: 'distributed mode documentation' })).toHaveAttribute('href', 'https://localai.io/features/distributed-mode/')
|
|
await expect(page.getByRole('heading', { name: 'Run this on the new machine' })).toBeVisible()
|
|
})
|
|
|
|
test.describe('over P2P', () => {
|
|
const p2p = { token: 'net-token-1', stats: { federated: { online: 1, total: 1 }, llama_cpp_workers: { online: 0, total: 0 }, mlx_workers: { online: 0, total: 0 } } }
|
|
|
|
test('a peer instance joins with the network token', async ({ page }) => {
|
|
await mockSwarm(page, { p2p })
|
|
await page.goto('/app/nodes/add?join=peer')
|
|
await expect(page.getByRole('radio', { name: 'Peer instance' })).toHaveAttribute('aria-checked', 'true', { timeout: 15_000 })
|
|
await expect(command(page)).toContainText('-e TOKEN="net-token-1"')
|
|
await expect(command(page)).toContainText('run --federated --p2p')
|
|
await expect(page.getByText('1 peers online')).toBeVisible()
|
|
})
|
|
|
|
test('a memory shard offers llama.cpp RPC and MLX', async ({ page }) => {
|
|
await mockSwarm(page, { p2p })
|
|
await page.goto('/app/nodes/add?join=shard')
|
|
await expect(command(page)).toContainText('worker p2p-llama-cpp-rpc', { timeout: 15_000 })
|
|
await page.getByRole('radio', { name: 'MLX (Apple Silicon)' }).click()
|
|
await expect(command(page)).toContainText('worker p2p-mlx')
|
|
await expect(command(page)).toContainText('latest-metal-darwin-arm64')
|
|
// MLX runs on a Mac: GPU build targets do not apply.
|
|
await expect(page.getByRole('radio', { name: 'CUDA 12', exact: true })).toHaveCount(0)
|
|
})
|
|
|
|
test('says to turn P2P on first when it is off', async ({ page }) => {
|
|
await mockSwarm(page)
|
|
await page.goto('/app/nodes/add?join=peer')
|
|
await expect(page.getByRole('heading', { name: 'Turn on P2P first' })).toBeVisible({ timeout: 15_000 })
|
|
await expect(page.getByTestId('command-block').first()).toContainText('run --p2p')
|
|
await expect(page.getByTestId('command-block').last()).toContainText('your-token-here')
|
|
})
|
|
|
|
test('shows a new peer when the online count goes up', async ({ page }) => {
|
|
let calls = 0
|
|
await mockSwarm(page)
|
|
await page.route('**/api/p2p/token', route => route.fulfill({ status: 200, contentType: 'text/plain', body: 'net-token-1' }))
|
|
await page.route('**/api/p2p/stats', route => {
|
|
calls += 1
|
|
const online = calls < 3 ? 1 : 2
|
|
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ federated: { online, total: 2 }, llama_cpp_workers: { online: 0, total: 0 }, mlx_workers: { online: 0, total: 0 } }) })
|
|
})
|
|
await page.goto('/app/nodes/add?join=peer')
|
|
await expect(page.getByTestId('found-peer')).toContainText('A new peer joined: 2 online', { timeout: 20_000 })
|
|
})
|
|
})
|
|
|
|
test('first run of a cluster carries the same steps in place', async ({ page }) => {
|
|
await mockSwarm(page, { nodes: [] })
|
|
await page.goto('/app/nodes')
|
|
await expect(page.getByTestId('swarm-first-run')).toBeVisible({ timeout: 15_000 })
|
|
await expect(page.getByRole('heading', { name: 'How should it join?' })).toBeVisible()
|
|
await expect(command(page)).toContainText('LOCALAI_REGISTER_TO')
|
|
})
|
|
|
|
test('a phone scrolls the command sideways instead of the page', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await mockSwarm(page)
|
|
await page.goto('/app/nodes/add')
|
|
const block = command(page).locator('pre')
|
|
await expect(block).toBeVisible({ timeout: 15_000 })
|
|
const { scrollWidth, clientWidth } = await block.evaluate(el => ({ scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }))
|
|
expect(scrollWidth).toBeGreaterThan(clientWidth)
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
|
|
const copy = await page.getByRole('button', { name: 'Copy command' }).last().boundingBox()
|
|
expect(copy.x + copy.width).toBeLessThanOrEqual(390)
|
|
// The method switch scrolls too; none of its choices is cut off for good.
|
|
await page.getByRole('radio', { name: 'Memory shard' }).scrollIntoViewIfNeeded()
|
|
await expect(page.getByRole('radio', { name: 'Memory shard' })).toBeInViewport()
|
|
})
|
|
})
|