mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
A visual pass over the add-on pages found the same few problems on several of them. Fix them with one rule per problem instead of page by page. Titles: every add-on page takes the host page-title size. Talk and Tables no longer shrink it, Studio draws one "Studio" title above its tabs (the card of a generator is a section), and Voice keeps one title on all its tabs. The Text to speech tab gets a styled form. The "not available" page shows the add-on name, not the lowercase id, and sits at the same inset as an add-on page. One primary action: the empty state of a list carries the add button, so the header button waits until there is something in the list (Voice library, enrolling a speaker or a person, Add rows). The Add-ons page shows one way into Browse. A table with no columns says so once. Tables: Share is hidden when there are no named accounts. The active table tab is highlighted and an empty tab list leaves no gap before "New table". Studio: the tab strip stays on one row at 1280 wide, /studio/3d opens the 3D tab, and an input chip has a divider between its label and its placeholder. Fine-tune and Quantize: the Experimental badge sits beside the lede, not inside the h1; an open step shows its number instead of an empty circle; the sticky action bar keeps a gap above it. The footer shows nothing for the empty version of a source build, and the Voice Text to speech tab says where to get a model when none is installed. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
109 lines
5.9 KiB
JavaScript
109 lines
5.9 KiB
JavaScript
import { test, expect } from '@playwright/test'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { MEMBER, app, sessionFor, mockAddons } from './addons-fixtures.js'
|
|
|
|
// The add-on page shell against the stand-in routes of addons-fixtures.js: the
|
|
// fixture package's own module and style sheet are served as an owner would
|
|
// serve them. This runs in the default suite (no add-on owner needed); the
|
|
// real-authorization specs are in addons-runtime.spec.js (esm-ui suite).
|
|
|
|
const fixture = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../tests/addons/fixture/ui/uifixture')
|
|
const DIGEST = 'c'.repeat(64)
|
|
const base = `/api/addons/uifixture/assets/${DIGEST}`
|
|
|
|
function uiApp(over = {}) {
|
|
return app({
|
|
id: 'uifixture', name: 'uifixture', title: 'UI fixture', icon: undefined, generation: 1, mainPage: '/app/addons/uifixture/home',
|
|
ui: { entry: `${base}/ui.js`, css: [`${base}/ui/style.css`], sdk: { min: 1, max: 2 }, pages: ['home', 'boom', 'dialog'], navigation: ['home'], chatActions: ['summarize'] },
|
|
...over,
|
|
})
|
|
}
|
|
|
|
async function serve(page) {
|
|
await page.route(/\/api\/addons\/uifixture\/assets\//, route => {
|
|
const file = new URL(route.request().url()).pathname.split('/').slice(6).join('/')
|
|
const known = { 'ui.js': 'text/javascript', 'ui/style.css': 'text/css' }
|
|
if (!known[file]) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } })
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: known[file],
|
|
headers: { 'cache-control': 'private, max-age=31536000, immutable', 'x-content-type-options': 'nosniff' },
|
|
body: fs.readFileSync(path.join(fixture, file)),
|
|
})
|
|
})
|
|
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true }) }))
|
|
}
|
|
|
|
test.describe('Add-on shell', () => {
|
|
test('a module served by the owner draws its page with the host React and scoped styles', async ({ page }) => {
|
|
await serve(page)
|
|
await mockAddons(page, { apps: [uiApp()] })
|
|
await page.goto('/app/addons/uifixture/home')
|
|
const surface = page.locator('[data-testid="addon-surface"][data-addon="uifixture"]')
|
|
await expect(surface).toBeVisible()
|
|
await expect(page.getByTestId('fx-title')).toHaveText('Hello fixture')
|
|
await page.getByRole('button', { name: 'Increment' }).click()
|
|
await expect(surface.getByText('hooks 1')).toBeVisible()
|
|
// The shell draws no title over the add-on page, and the sheet's `h1`
|
|
// rule never reaches the host's own chrome.
|
|
await expect(page.locator('h1.page-title')).toHaveCount(0)
|
|
await expect(page.locator('.sidebar-nav a, .sidebar-nav button').first()).not.toHaveCSS('font-size', '11px')
|
|
await expect(page.locator('style[data-addon-style="uifixture"]')).toHaveCount(1)
|
|
// The detail view of the add-on links the declared navigation page.
|
|
await page.goto('/app/addons?detail=uifixture')
|
|
await expect(page.getByTestId('addon-detail').getByRole('link', { name: 'UI fixture', exact: true })).toHaveAttribute('href', '/app/addons/uifixture/home')
|
|
})
|
|
|
|
test('a page that throws is contained and an undeclared page is a missing page', async ({ page }) => {
|
|
await serve(page)
|
|
await mockAddons(page, { apps: [uiApp()] })
|
|
await page.goto('/app/addons/uifixture/boom')
|
|
await expect(page.getByTestId('addon-error')).toBeVisible()
|
|
await expect(page.locator('.sidebar-nav')).toBeVisible()
|
|
await page.goto('/app/addons/uifixture/other')
|
|
await expect(page.getByTestId('addon-missing-page')).toBeVisible()
|
|
})
|
|
|
|
test('an add-on that is not usable says why and an unknown SDK range is never imported', async ({ page }) => {
|
|
await serve(page)
|
|
const requested = []
|
|
page.on('request', r => { if (/\/assets\//.test(r.url()) && r.url().includes('/api/addons/')) requested.push(r.url()) })
|
|
await mockAddons(page, { apps: [uiApp({ state: 'failed' }), uiApp({ id: 'future', name: 'future', title: 'Future', ui: { ...uiApp().ui, entry: '/api/addons/future/assets/' + DIGEST + '/ui.js', css: [], sdk: { min: 3, max: 3 } } })] })
|
|
await page.goto('/app/addons/uifixture/home')
|
|
await expect(page.getByTestId('addon-unavailable')).toHaveAttribute('data-reason', 'failed')
|
|
// No add-on page can be drawn here, so the shell names the add-on, once.
|
|
await expect(page.locator('h1')).toHaveCount(1)
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('UI fixture')
|
|
await page.goto('/app/addons/future/home')
|
|
await expect(page.getByTestId('addon-unavailable')).toHaveAttribute('data-reason', 'incompatible_sdk')
|
|
await expect(page.locator('h1')).toHaveCount(1)
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Future')
|
|
expect(requested).toEqual([])
|
|
})
|
|
|
|
test('an add-on that is not listed is titled with its name, not the raw identifier', async ({ page }) => {
|
|
await serve(page)
|
|
await mockAddons(page, { apps: [uiApp()] })
|
|
await page.goto('/app/addons/voice/home')
|
|
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Voice')
|
|
})
|
|
|
|
test('a withdrawn add-on is unmounted on the next list and Reload brings it back', async ({ page }) => {
|
|
await serve(page)
|
|
const state = await mockAddons(page, { apps: [uiApp()] })
|
|
await page.goto('/app/addons/uifixture/home')
|
|
await expect(page.getByTestId('fx-title')).toBeVisible()
|
|
state.apps[0].enabled = false
|
|
state.apps[0].state = 'stopped'
|
|
await expect(page.getByTestId('addon-unavailable')).toHaveAttribute('data-reason', 'disabled', { timeout: 15_000 })
|
|
await expect(page.locator('style[data-addon-style="uifixture"]')).toHaveCount(0)
|
|
state.apps[0].enabled = true
|
|
state.apps[0].state = 'running'
|
|
await page.getByTestId('addon-reload').click()
|
|
await expect(page.getByTestId('fx-title')).toBeVisible({ timeout: 15_000 })
|
|
})
|
|
})
|