mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 17:18:49 -04:00
The pages are add-ons now, so a spec that draws one installs the real built bundle first: the apps list names it and its module and style sheet are served from the build output, the way the add-on owner serves them. studio-addon.js holds that helper, and the Studio and Talk fixtures call it. Update the specs for the new addresses (/app/addons/studio/studio, the model as ?model=), the session permission the add-on route needs, the legacy redirects, the Home /studio command that appears only while the add-on is installed, and the sidebar without a Create group. Add specs for the add-ons themselves: every tab draws with no script error, the shell titles the page once, the old addresses say so when the add-on is not installed, a button of the page reaches a core page with the way back, and the drawer lists both only after they are installed. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
190 lines
8.6 KiB
JavaScript
190 lines
8.6 KiB
JavaScript
import { test, expect } from './coverage-fixtures.js'
|
|
import { composer, pickType } from './studio-fixtures.js'
|
|
import { installStudioAddons } from './studio-addon.js'
|
|
|
|
// Studio overview (src/pages/StudioOverview.jsx).
|
|
//
|
|
// Studio was a tab strip over six generators that opened on Images and told you
|
|
// nothing about what this machine could actually run. The tests are about that:
|
|
// what the strip reports before you click, and the difference between a
|
|
// modality that is switched off and one that merely has no model. The front
|
|
// page is now a composer with a chip per type, so "a lane" reads "a chip":
|
|
// a type with a model is solid, a type without one is dashed.
|
|
|
|
const OVERVIEW = '[data-testid="studio-overview"]'
|
|
const tabFor = (page, key) => page.locator(`.studio-tab[data-tab="${key}"]`)
|
|
|
|
const model = (id, ...capabilities) => ({ id, capabilities })
|
|
|
|
// Images and speech covered, video and sound not. 3D and transform are feature
|
|
// flags rather than models, so they are controlled separately.
|
|
const SOME_MODELS = {
|
|
data: [
|
|
model('flux.1-schnell', 'FLAG_IMAGE'),
|
|
model('kokoro-82m', 'FLAG_TTS'),
|
|
model('qwen3-8b', 'FLAG_CHAT'),
|
|
],
|
|
}
|
|
|
|
async function mockCapabilities(page, payload = SOME_MODELS) {
|
|
// Studio is an add-on: install the built bundle before the page opens.
|
|
await installStudioAddons(page, { ids: ['studio'] })
|
|
await page.route('**/api/models/capabilities', route => route.fulfill({ json: payload }))
|
|
}
|
|
|
|
test.describe('Studio overview', () => {
|
|
test('Studio opens on the overview rather than dropping into Images', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio')
|
|
await expect(page.locator(OVERVIEW)).toBeVisible()
|
|
})
|
|
|
|
test('a generator path opens that generator', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio/images')
|
|
await expect(page.locator(OVERVIEW)).toHaveCount(0)
|
|
await expect(page.locator('[data-testid="studio-workspace"]')).toBeVisible()
|
|
})
|
|
|
|
test('an unrecognised tab falls back to the overview, not to Images', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio/nonsense')
|
|
await expect(page.locator(OVERVIEW)).toBeVisible()
|
|
})
|
|
|
|
test('the tab strip reports which modalities have a model', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio')
|
|
// Filled: something installed advertises the capability.
|
|
await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible()
|
|
await expect(tabFor(page, 'tts').locator('.studio-tab__dot--on')).toBeVisible()
|
|
// Hollow: the modality is available, nothing serves it yet.
|
|
await expect(tabFor(page, 'video').locator('.studio-tab__dot--off')).toBeVisible()
|
|
await expect(tabFor(page, 'sound').locator('.studio-tab__dot--off')).toBeVisible()
|
|
})
|
|
|
|
test('the tab dots are quiet and the state is spoken, not only drawn', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio')
|
|
await expect(tabFor(page, 'images')).toContainText('(model installed)')
|
|
await expect(tabFor(page, 'video')).toContainText('(no model installed)')
|
|
await expect(page.getByRole('button', { name: /^Images \(model installed\)$/ })).toBeVisible()
|
|
// Neither state is drawn in the success colour.
|
|
const colours = await page.evaluate(() => {
|
|
const root = getComputedStyle(document.documentElement)
|
|
const success = root.getPropertyValue('--color-success').trim()
|
|
const probe = document.createElement('i')
|
|
probe.style.color = success
|
|
document.body.appendChild(probe)
|
|
const resolved = getComputedStyle(probe).color
|
|
probe.remove()
|
|
return {
|
|
success: resolved,
|
|
on: getComputedStyle(document.querySelector('.studio-tab__dot--on')).backgroundColor,
|
|
}
|
|
})
|
|
expect(colours.on).not.toBe(colours.success)
|
|
})
|
|
|
|
test('a modality with no model offers a way to install one', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio')
|
|
// A hollow dot on the tab, because nothing serves it. Picking it in the
|
|
// composer is how you learn what it needs.
|
|
await expect(tabFor(page, 'video').locator('.studio-tab__dot--off')).toBeVisible()
|
|
await expect(page.locator('[data-testid="studio-install-note"]')).toHaveCount(0)
|
|
await pickType(page, 'video')
|
|
await expect(page.locator('[data-testid="studio-install-note"]')).toBeVisible()
|
|
})
|
|
|
|
test('a modality with a model names it instead of offering an install', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio')
|
|
await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible()
|
|
await expect(page.locator('[data-testid="studio-model"]')).toHaveValue('flux.1-schnell')
|
|
await expect(page.locator('[data-testid="studio-install-note"]')).toHaveCount(0)
|
|
})
|
|
|
|
test('a disabled feature gets no tab and no composer type', async ({ page }) => {
|
|
// Switched off is a different thing from "no model installed", and
|
|
// conflating them is how someone ends up staring at a control that cannot
|
|
// work. 3d is a permission rather than an /api/features entry, and
|
|
// hasFeature() short-circuits to true for admins and for auth-off
|
|
// installations, so withholding it needs a real non-admin session.
|
|
await page.route('**/api/auth/status', route => route.fulfill({
|
|
json: {
|
|
authEnabled: true,
|
|
user: { name: 'someone', role: 'user', permissions: { addons: true, images: true, video: true, tts: true, sound: true } },
|
|
},
|
|
}))
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio')
|
|
await expect(page.locator(OVERVIEW)).toBeVisible()
|
|
await expect(tabFor(page, 'threed')).toHaveCount(0)
|
|
await expect(composer(page)).not.toHaveAttribute('data-types', /threed/)
|
|
})
|
|
|
|
test('asks the capabilities endpoint once, not once per modality', async ({ page }) => {
|
|
let calls = 0
|
|
await installStudioAddons(page, { ids: ['studio'] })
|
|
await page.route('**/api/models/capabilities', route => {
|
|
calls += 1
|
|
route.fulfill({ json: SOME_MODELS })
|
|
})
|
|
await page.goto('/app/studio')
|
|
await expect(page.locator(OVERVIEW)).toBeVisible()
|
|
await page.waitForTimeout(500)
|
|
// useModels() fetches the whole list and filters in the browser, so one
|
|
// hook per modality would be six identical requests on every mount.
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
test('an installation with no models at all still renders every modality', async ({ page }) => {
|
|
await mockCapabilities(page, { data: [] })
|
|
await page.goto('/app/studio')
|
|
await expect(page.locator(OVERVIEW)).toBeVisible()
|
|
await expect(composer(page)).toBeVisible()
|
|
await expect(page.locator('.studio-tab__dot--off')).toHaveCount(7)
|
|
await expect(page.locator('.studio-tab__dot--on')).toHaveCount(0)
|
|
})
|
|
|
|
test('earlier results surface in Your work', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
// History is localStorage, written by each generator. The front page is the
|
|
// first place it is read across modalities rather than within one.
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem('localai_image_history', JSON.stringify([
|
|
{ id: 'i1', createdAt: Date.now(), model: 'flux.1-schnell', prompt: 'a brass orrery', results: [] },
|
|
]))
|
|
})
|
|
await page.goto('/app/studio')
|
|
const tile = page.locator('[data-testid="work-tile"]')
|
|
await expect(tile).toHaveCount(1)
|
|
await expect(tile).toContainText('flux.1-schnell')
|
|
await expect(tile).toContainText('a brass orrery')
|
|
})
|
|
|
|
test('no history means no tiles, and an empty state that says what happens next', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio')
|
|
await expect(page.locator('[data-testid="work-tile"]')).toHaveCount(0)
|
|
await expect(page.locator('[data-testid="studio-work-empty"]')).toContainText('Nothing here yet')
|
|
})
|
|
|
|
test('the overview is reachable back from a generator tab', async ({ page }) => {
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio/images')
|
|
await tabFor(page, 'overview').click()
|
|
await expect(page.locator(OVERVIEW)).toBeVisible()
|
|
})
|
|
|
|
test('a legacy ?tab= link is redirected to its path', async ({ page }) => {
|
|
// Bookmarks and older docs still use the query form; they must keep working
|
|
// and must land on the canonical URL rather than a second spelling of it.
|
|
await mockCapabilities(page)
|
|
await page.goto('/app/studio?tab=images')
|
|
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images$/)
|
|
await expect(page.locator('[data-testid="studio-workspace"]')).toBeVisible()
|
|
})
|
|
})
|