Files
LocalAI/core/http/react-ui/e2e/addons-gallery.spec.js
T
mudler-agentandEttore Di Giacinto c6687ce67c feat(addons): add-ons UI direction B: one name, Turn on and Turn off, preview pane (#12858)
* refactor(addons): one name and one verb pair in the add-ons UI

The sidebar, the page and the tabs now say Add-ons. The tabs are My
add-ons, Find add-ons and Sources. Turn on and Turn off replace Enable,
Start, Disable and Stop wherever they label a button, and the state words
read On and Off. The subtitle no longer names the owner.

Other locales get the new keys; the main labels are translated and the
rest carry the English text until a translator refines them. A unit test
checks that every locale has the keys of English.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): show what an add-on does on its card and move actions to a menu

A card now shows the add-on description, an "Adds a page" line and the
number of chat tools when they apply. The pin button and the bare arrows
are replaced by one overflow menu with Show in sidebar, Details and
Remove. Reordering a pinned add-on stays in the same menu. Remove asks
first and runs the typed uninstall action; only administrators see it.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add a preview pane for the selected add-on

Selecting a card, or choosing Details in its menu, opens a pane beside
the list. It shows the name and state, the description, what the add-on
adds, Turn on or Turn off and Remove, and its version and source. The pane
replaces the modal sheet and uses the data the list already has. The
source comes from the installed list that the host already serves to
administrators. Screenshot slots show only when the host lists images for
the add-on; today it lists none, so no slot is drawn.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): clear first-run and empty states for My add-ons

With nothing installed, an administrator now sees three add-ons from the
sources, each with Install and turn on, and a link to Find add-ons. A
search that finds nothing says that no add-on matches and offers to clear
the search, instead of saying that nothing is installed. Install and turn
on is one action of the user: it installs, then turns the add-on on, with
a separate operation identity for each step and the progress or the reason
for a failure shown on the card.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): one button and folded details on Find add-ons entries

Each entry shows its description, an "Adds a page" label when the library
entry declares a main page, and one button: Install and turn on. An
add-on that is installed but off offers Turn on. Details is folded into
the entry and shows the trust and build facts the entry already carries,
so the separate details sheet is gone. A backend keeps a plain Install,
because a backend has no on or off.

The library entry carries no tool count, so the entry cannot say how many
chat tools an add-on adds. That label appears on My add-ons, where the
list knows it.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): install and turn on in one dialog with inline progress

Install and turn on now asks once. The dialog then stays open and shows
two steps, the install and the turn on, and ends on the result or on the
reason a step failed with a way to try again. A failed turn on keeps the
install and the retry only turns the add-on on. Each step is its own
action with its own operation identity, so an unknown outcome still
blocks further actions on the add-on until it is read or dismissed. The
first-run cards use the same dialog. The two-step flow is a plain function
with unit tests.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(addons): fold the Installed tab into My add-ons

The page now has three tabs: My add-ons, Find add-ons and Sources. What
the Installed tab showed is in the preview pane of each add-on, under
More details for administrators: health, trust, compatibility, storage,
data recovery and updates, with Check for updates and Upgrade. Remove
and Turn on or Turn off are on the card and in the pane. An old
?tab=installed address opens My add-ons, and the management link
?manage=ID opens the details of that add-on. A card shows an update mark
when the host reports one. Closing the pane returns focus to its card.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe the new add-ons page labels

Update the add-ons feature page and the pages that point at it: the
sidebar item and the page are called Add-ons, the tabs are My add-ons,
Find add-ons and Sources, Turn on and Turn off replace Start and Stop,
the card menu replaces the pin control, and the preview pane holds what
the Installed tab showed.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): say what is wrong on the page of an add-on that cannot open

The page of an add-on that cannot open now depends on the add-on. One
that is installed but off says "<name> is off" and offers Turn on to an
administrator. One that is not installed says so and sends an
administrator to Find add-ons. One that failed shows the reason and a link
to its details. A reader gets a line that says who can fix it, and no
button. The page title is the name of the add-on, with a capital first
letter when the host lists no title, and no longer the bare identifier.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): put the add-on tools control next to MCP and say what off means

The add-on tools control moves from the chat header to the composer, right
after the MCP control, because both decide what the model may call in this
chat. Its menu starts with one line: an add-on that is off here does
nothing in this chat. Only add-ons that ship tools appear in it, as
before.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(addons): clear lint warnings of the new components

Add the lint header the add-on components use for JSX-only imports, drop
an unneeded directive, stop exporting a helper from a component file and
extract the dependencies of an effect.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): update the backend library specs for the folded entry details

A backend says its kind on the entry, an add-on says it under Details,
Details is a fold of the entry and an installed backend has no Install
button.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-23 15:25:37 +02:00

726 lines
42 KiB
JavaScript

import { test, expect } from '@playwright/test'
import { ADMIN, MEMBER, HEADLESS, app, installedAddon, release, sessionFor, mockAddons, ok, fail } from './addons-fixtures.js'
// The add-ons page (src/pages/Addons.jsx): My add-ons for everyone, Find add-ons,
// Installed and Sources for administrators. The server is the authority for
// every answer, so these specs drive the page against a stand-in that gives
// the same refusals the real routes give, and assert what the page sent.
const admin = { canStart: false, canStop: true, managementUrl: '/app/addons?manage=notes' }
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
async function signIn(page, user = ADMIN, permissions = {}) {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, permissions) }))
}
const shots = process.env.ADDONS_SHOTS
async function shoot(page, name) {
if (!shots) return
// Capture the settled page, not a frame of the fade-in.
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.waitForTimeout(400)
await page.screenshot({ path: `${shots}/${name}.png`, fullPage: true })
}
const tab = (page, name) => page.getByRole('tab', { name })
// The start, stop and tool controls of an add-on live in the detail sheet of the drawer.
const detail = (page, id) => page.getByTestId('addon-detail').locator(`[data-app="${id}"]`)
test.describe('Add-ons: apps', () => {
test('apps: lists enabled apps with state chips, the icon route and Open only for a main page', async ({ page }) => {
await signIn(page, MEMBER, { addons: true })
await mockAddons(page, { apps: [app(), HEADLESS, app({ id: 'broken', name: 'broken', title: 'Broken', state: 'failed', icon: undefined, mainPage: undefined, hasUI: true })] })
await page.goto('/app/addons')
const notes = page.locator('[data-app="notes"]')
await expect(notes.locator('.ad-tile__title')).toHaveText('Notes')
await expect(notes.locator('.ad-tile__state')).toHaveText('On')
await expect(notes.getByRole('link')).toHaveAttribute('href', '/app/addons/notes/home')
await expect(notes.locator('img.ad-icon')).toHaveAttribute('src', '/api/addons/apps/notes/icon?generation=3')
await expect(page.locator('[data-app="broken"] .ad-tile__state')).toHaveText('Off, failed')
await expect(page.locator('[data-app="broken"]').getByRole('link')).toHaveCount(0)
await expect(page.locator('[data-app="indexer"]').getByRole('link')).toHaveCount(0)
// Every image is served by the host itself, never from another origin.
const sources = await page.locator('.ad-tiles img').evaluateAll(imgs => imgs.map(i => new URL(i.src).origin))
expect(new Set(sources)).toEqual(new Set([new URL(page.url()).origin]))
await shoot(page, 'apps-wide')
})
test('apps: a headless add-on shows its tools and no Open action', async ({ page }) => {
await signIn(page, MEMBER, { addons: true })
await mockAddons(page, { apps: [HEADLESS] })
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await expect(card.getByTestId('app-tools')).toContainText('3 chat tools')
await expect(card.getByTestId('app-tools')).toContainText('query, export, reindex')
await expect(card.getByText('Runs in the background')).toBeVisible()
await expect(card.getByRole('link')).toHaveCount(0)
})
test('apps: a reader gets no start, stop, manage or management tabs', async ({ page }) => {
await signIn(page, MEMBER, { addons: true })
await mockAddons(page)
await page.goto('/app/addons?tab=libraries')
await expect(page.locator('[data-app="notes"]')).toBeVisible()
await expect(page.getByRole('tablist')).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0)
await expect(page.getByText('Add a library')).toHaveCount(0)
// The preview pane offers the reader nothing to turn on, turn off or manage either.
await page.goto('/app/addons?detail=notes')
await expect(detail(page, 'notes')).toBeVisible()
await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0)
})
test('apps: an administrator starts an app with a fresh operation ID and the listed generation', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page, { apps: [app({ id: 'indexer', title: 'Indexer', enabled: false, state: 'stopped', generation: 4, mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })] })
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await expect(card.getByRole('img', { name: 'State: Off' })).toBeVisible()
await card.getByRole('button', { name: 'Turn on' }).click()
await expect(card.getByRole('img', { name: 'State: On' })).toBeVisible()
expect(state.log.start).toHaveLength(1)
expect(state.log.start[0].generation).toBe(4)
expect(state.log.start[0].operationId).toMatch(UUID)
expect(state.log.stop).toHaveLength(0)
})
test('apps: a lost start is read with the same operation ID and never submitted again', async ({ page }) => {
await signIn(page, ADMIN)
let reads = 0
const state = await mockAddons(page, {
apps: [app({ id: 'indexer', title: 'Indexer', enabled: false, state: 'stopped', generation: 2, mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })],
start: (body) => ({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { type: 'outcome_unknown', message: 'unknown', operationId: body.operationId } }) }),
dispatch: (body, st) => {
if (body.action !== 'operation-status') return null
reads += 1
if (reads >= 2) { st.apps[0].state = 'running'; st.apps[0].enabled = true }
return ok({ OperationID: body.operationId, AddonID: body.addonId, Phase: reads >= 2 ? 'completed' : 'prepared', Generation: 2 })
},
})
await page.goto('/app/addons?detail=indexer')
await detail(page, 'indexer').getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => reads, { timeout: 15_000 }).toBeGreaterThanOrEqual(2)
expect(state.log.start).toHaveLength(1)
const ids = new Set([state.log.start[0].operationId, ...state.log.dispatch.filter(d => d.action === 'operation-status').map(d => d.operationId)])
expect(ids.size).toBe(1)
})
// The owner answers a replayed operation ID with the old status and applies
// nothing. This stand-in does the same, so a reused ID is a visible no-op.
function replayingOwner(state) {
const done = new Map()
const handler = (verb) => (body) => {
const known = done.get(body.operationId)
if (known) return ok({ OperationID: body.operationId, AddonID: body.id, Phase: 'completed', Generation: body.generation })
done.set(body.operationId, verb)
return null
}
return { start: handler('start'), stop: handler('stop'), done, state }
}
test('apps: an unknown start is resolved or dismissed before the next action and never replays its ID', async ({ page }) => {
test.setTimeout(90_000)
await signIn(page, ADMIN)
let reads = 0
let lose = true
const owner = replayingOwner(null)
const state = await mockAddons(page, {
apps: [app({ id: 'indexer', title: 'Indexer', enabled: false, state: 'stopped', generation: 2, mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })],
start: (body) => {
const replay = owner.start(body)
if (replay) return replay
if (lose) {
// The owner applies the start but the answer is lost.
const a = state.apps[0]
a.enabled = true; a.state = 'running'; a.admin = { canStart: false, canStop: true, managementUrl: a.admin.managementUrl }
return { status: 503, contentType: 'application/json', body: JSON.stringify({ error: { type: 'outcome_unknown', message: 'unknown', operationId: body.operationId } }) }
}
return null
},
stop: (body) => owner.stop(body),
dispatch: (body) => {
if (body.action !== 'operation-status') return null
reads += 1
// Polling is exhausted first; an explicit check later finds it done.
return ok({ OperationID: body.operationId, AddonID: body.addonId, Phase: lose && reads <= 6 ? 'prepared' : 'completed', Generation: 2 })
},
})
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await card.getByRole('button', { name: 'Turn on' }).click()
const notice = card.getByTestId('unknown-outcome')
await expect(notice).toBeVisible({ timeout: 40_000 })
expect(state.log.start).toHaveLength(1)
const firstId = state.log.start[0].operationId
lose = false
// The list now shows the app running, but a new action on it is blocked
// until the unknown one is read or dismissed.
await expect(card.getByRole('button', { name: 'Turn off' })).toBeVisible({ timeout: 15_000 })
await expect(card.getByRole('button', { name: 'Turn off' })).toBeDisabled()
expect(state.log.stop).toHaveLength(0)
await notice.getByRole('button', { name: 'Check status' }).click()
await expect(notice).toHaveCount(0)
expect(state.log.start).toHaveLength(1)
// Stop is a new operation.
await card.getByRole('button', { name: 'Turn off' }).click()
await expect.poll(() => state.log.stop.length).toBe(1)
expect(state.log.stop[0].operationId).toMatch(UUID)
expect(state.log.stop[0].operationId).not.toBe(firstId)
await expect(card.getByRole('button', { name: 'Turn on' })).toBeVisible()
// Start again: a fresh ID, applied by the owner, not replayed as a no-op.
await card.getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => state.log.start.length).toBe(2)
expect(state.log.start[1].operationId).not.toBe(firstId)
await expect(card.getByRole('img', { name: 'State: On' })).toBeVisible()
await expect(card.getByRole('alert')).toHaveCount(0)
expect(state.apps[0].enabled).toBe(true)
})
test('apps: dismissing an unknown outcome lets the next click mint a new operation', async ({ page }) => {
test.setTimeout(90_000)
await signIn(page, ADMIN)
const state = await mockAddons(page, {
apps: [app({ id: 'indexer', title: 'Indexer', enabled: false, state: 'stopped', generation: 2, mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })],
start: (body, n) => (n === 1
? { status: 503, contentType: 'application/json', body: JSON.stringify({ error: { type: 'outcome_unknown', message: 'unknown', operationId: body.operationId } }) }
: null),
dispatch: (body) => (body.action === 'operation-status' ? ok({ OperationID: body.operationId, AddonID: body.addonId, Phase: 'prepared', Generation: 2 }) : null),
})
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await card.getByRole('button', { name: 'Turn on' }).click()
const notice = card.getByTestId('unknown-outcome')
await expect(notice).toBeVisible({ timeout: 40_000 })
await expect(card.getByRole('button', { name: 'Turn on' })).toBeDisabled()
await notice.getByRole('button', { name: 'Dismiss' }).click()
await expect(notice).toHaveCount(0)
await card.getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => state.log.start.length).toBe(2)
expect(state.log.start[1].operationId).not.toBe(state.log.start[0].operationId)
})
test('apps: a completed status that left the app unchanged is not shown as done', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, {
apps: [app({ id: 'indexer', title: 'Indexer', enabled: false, state: 'stopped', generation: 2, mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })],
// A replay answers completed and applies nothing.
start: (body) => ok({ OperationID: body.operationId, AddonID: 'indexer', Phase: 'completed', Generation: 2 }),
})
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await card.getByRole('button', { name: 'Turn on' }).click()
await expect(card.getByRole('alert')).toContainText('did not change')
await expect(card.getByRole('img', { name: 'State: Off' })).toBeVisible()
})
test('apps: a refused start shows the server denial and refreshes the list', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page, {
apps: [app({ id: 'indexer', title: 'Indexer', enabled: false, state: 'stopped', generation: 2, mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })],
start: () => fail(403, 'addon_error'),
})
await page.goto('/app/addons?detail=indexer')
const before = state.log.appsList
await detail(page, 'indexer').getByRole('button', { name: 'Turn on' }).click()
await expect(page.getByRole('alert').filter({ hasText: 'addon_error' })).toBeVisible()
await expect.poll(() => state.log.appsList).toBeGreaterThan(before)
})
test('apps: the list refreshes on a bounded interval, backs off on errors and stops when left', async ({ page }) => {
await signIn(page, MEMBER, { addons: true })
await page.clock.install()
const state = await mockAddons(page, { appsList: (n) => (n === 2 || n === 3 ? fail(503, 'addon_error') : null) })
await page.goto('/app/addons')
await expect(page.locator('[data-app="notes"]')).toBeVisible()
expect(state.log.appsList).toBe(1)
// From here only runFor moves the page clock, so a slow machine cannot make
// a refresh come early.
await page.clock.pauseAt((await page.evaluate(() => Date.now())) + 50)
await page.clock.runFor(4_900)
expect(state.log.appsList).toBe(1)
await page.clock.runFor(200)
await expect.poll(() => state.log.appsList).toBe(2)
// The failure doubles the wait, and the old list stays visible and flagged.
await expect(page.getByText('may be out of date')).toBeVisible()
await page.clock.runFor(9_000)
expect(state.log.appsList).toBe(2)
await page.clock.runFor(1_500)
await expect.poll(() => state.log.appsList).toBe(3)
await page.clock.runFor(21_000)
await expect.poll(() => state.log.appsList).toBe(4)
await expect(page.getByText('may be out of date')).toHaveCount(0)
// Leaving the page stops the requests.
await page.evaluate(() => { window.history.pushState({}, '', '/app/chat'); window.dispatchEvent(new PopStateEvent('popstate')) })
await expect(page.locator('[data-app="notes"]')).toHaveCount(0)
const seen = state.log.appsList
await page.clock.runFor(180_000)
expect(state.log.appsList).toBe(seen)
})
})
test.describe('Add-ons: registry polling', () => {
const leave = (page) => page.evaluate(() => { window.history.pushState({}, '', '/app/chat'); window.dispatchEvent(new PopStateEvent('popstate')) })
const contributing = (over = {}) => app({ ui: { entry: '/api/addons/notes/assets/' + 'c'.repeat(64) + '/ui.js', css: [], sdk: { min: 1, max: 2 }, pages: ['home'], navigation: ['home'] }, ...over })
test('apps: an add-on that contributes UI keeps the list current off the page and its disable stops the reads', async ({ page }) => {
await signIn(page, MEMBER, { addons: true })
await page.clock.install()
const state = await mockAddons(page, { apps: [contributing()] })
await page.goto('/app/addons')
await expect(page.locator('[data-app="notes"]')).toBeVisible()
await leave(page)
await expect(page.locator('[data-app="notes"]')).toHaveCount(0)
await page.clock.pauseAt((await page.evaluate(() => Date.now())) + 50)
await expect.poll(() => state.log.appsList).toBeGreaterThanOrEqual(1)
// Reads continue, one per interval and no faster. The clock only moves
// by runFor, and each step waits for its answer before the next one.
const before = state.log.appsList
await page.clock.runFor(4_000)
await page.waitForTimeout(150)
expect(state.log.appsList).toBe(before)
await page.clock.runFor(1_100)
await expect.poll(() => state.log.appsList).toBe(before + 1)
for (let n = 2; n <= 4; n++) {
await page.clock.runFor(5_100)
await expect.poll(() => state.log.appsList).toBe(before + n)
}
// The owner disables it: the next list withdraws the contribution, and
// with nothing left to keep current the reads stop.
state.apps[0].enabled = false
state.apps[0].state = 'stopped'
await page.clock.runFor(5_100)
await expect.poll(() => state.log.appsList).toBe(before + 5)
await page.waitForTimeout(150)
await page.clock.runFor(180_000)
await page.waitForTimeout(300)
expect(state.log.appsList).toBe(before + 5)
})
test('apps: a viewer without the add-ons feature never requests the list', async ({ page }) => {
await signIn(page, MEMBER, {})
await page.clock.install()
const state = await mockAddons(page, { apps: [contributing()] })
await page.goto('/app/chat')
await page.clock.runFor(120_000)
await page.goto('/app/addons')
await page.clock.runFor(120_000)
expect(state.log.appsList).toBe(0)
})
})
test.describe('Add-ons: browse', () => {
test('browse: searches the libraries, pages and shows trust and platform facts', async ({ page }) => {
await signIn(page, ADMIN)
const many = Array.from({ length: 30 }, (_, i) => release(`addon-${String(i).padStart(2, '0')}`))
const state = await mockAddons(page, { releases: [release('tables', { policy: { requireSignature: true, issuer: 'https://issuer.example', identity: 'release@catalog.example' } }), ...many],
sources: [
{ id: 'main', name: 'Main', url: 'https://catalog.example/releases.json', enabled: true, policy: { requireSignature: false } },
{ id: 'team', name: 'Team', url: 'https://team.example/releases.json', enabled: true, policy: { requireSignature: false } },
] })
await page.goto('/app/addons?tab=browse')
await expect(page.locator('.cat-item')).toHaveCount(24)
await expect(page.getByText('31 results')).toBeVisible()
const tables = page.locator('.cat-item', { hasText: 'Tables' }).first()
// Trust and platform facts are folded into the entry, behind Details.
await expect(tables.getByText('linux/amd64')).toBeHidden()
await tables.getByText('Details', { exact: true }).click()
await expect(tables.getByText('Signature required')).toBeVisible()
await expect(tables.getByText('linux/amd64')).toBeVisible()
await expect(page.getByRole('group', { name: 'Libraries' }).getByRole('button', { name: 'Team' })).toBeVisible()
await page.getByRole('button', { name: 'Next page' }).click()
await expect(page).toHaveURL(/page=2/)
await expect(page.locator('.cat-item')).toHaveCount(7)
expect(state.log.dispatch.filter(d => d.action === 'catalog-search').at(-1).arguments.offset).toBe(24)
await page.getByRole('searchbox').or(page.getByLabel('Search add-ons in your sources')).first().fill('tables')
await expect(page.locator('.cat-item')).toHaveCount(1)
await expect(page).toHaveURL(/q=tables/)
expect(state.log.dispatch.filter(d => d.action === 'catalog-search').at(-1).arguments.text).toBe('tables')
await shoot(page, 'browse')
})
test('browse: Install and turn on asks once, installs and turns the add-on on', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page, { installed: [], releases: [release('tables', { name: 'Tables', description: 'Spreadsheets.', ui: { mainPage: '/app/addons/tables/home' } })] })
await page.goto('/app/addons?tab=browse')
const row = page.locator('.cat-item', { hasText: 'Tables' })
await expect(row).toContainText('Spreadsheets.')
await expect(row.getByTestId('entry-adds')).toHaveText('Adds a page')
// One button on the entry, and Details is folded into it.
await expect(row.getByRole('button')).toHaveCount(1)
await row.getByRole('button', { name: 'Install and turn on' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Install and turn on Tables?')
await expect(dialog).toContainText('sha256:aaaaaaaaaaaa')
expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
await dialog.getByRole('button', { name: 'Install and turn on' }).click()
// The same dialog stays open and shows the two steps, then the result.
await expect(dialog.getByTestId('flow-steps').getByRole('listitem')).toHaveCount(2)
await expect(dialog).toContainText('Tables is installed and on.')
await expect(dialog.getByTestId('flow-steps').locator('[data-step="install"]')).toHaveAttribute('data-state', 'done')
await expect(dialog.getByTestId('flow-steps').locator('[data-step="turn-on"]')).toHaveAttribute('data-state', 'done')
await dialog.getByRole('button', { name: 'Close' }).click()
await expect(page.getByRole('alertdialog')).toHaveCount(0)
await expect(row.getByText('Tables is installed and on.')).toBeVisible()
const install = state.log.dispatch.filter(d => d.action === 'install')
expect(install).toHaveLength(1)
expect(install[0].operationId).toMatch(UUID)
expect(install[0].arguments).toEqual({ sourceId: 'main', version: '1.0.0' })
const enable = state.log.dispatch.filter(d => d.action === 'enable')
expect(enable).toHaveLength(1)
expect(enable[0].operationId).toMatch(UUID)
expect(enable[0].operationId).not.toBe(install[0].operationId)
expect(state.installed.find(a => a.id === 'tables').enabled).toBe(true)
})
test('browse: a failed turn on keeps the install, says why in the dialog and can be tried again', async ({ page }) => {
await signIn(page, ADMIN)
let attempts = 0
const state = await mockAddons(page, {
installed: [],
releases: [release('tables', { name: 'Tables' })],
dispatch: (body) => {
if (body.action !== 'enable') return null
attempts += 1
return attempts === 1 ? fail(403, 'addon_error') : null
},
})
await page.goto('/app/addons?tab=browse')
const row = page.locator('.cat-item', { hasText: 'Tables' })
await row.getByRole('button', { name: 'Install and turn on' }).click()
const dialog = page.getByRole('alertdialog')
await dialog.getByRole('button', { name: 'Install and turn on' }).click()
await expect(dialog.getByTestId('install-dialog-failed')).toContainText('Tables is installed but did not turn on.')
await expect(dialog.getByTestId('flow-steps').locator('[data-step="install"]')).toHaveAttribute('data-state', 'done')
await expect(dialog.getByTestId('flow-steps').locator('[data-step="turn-on"]')).toHaveAttribute('data-state', 'failed')
// Trying again turns it on; it does not install a second time.
await dialog.getByRole('button', { name: 'Try again' }).click()
await expect(dialog).toContainText('Tables is installed and on.')
expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(1)
expect(state.log.dispatch.filter(d => d.action === 'enable')).toHaveLength(2)
})
test('browse: an add-on that is installed but off offers Turn on and nothing else', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page, { installed: [installedAddon({ id: 'tables', title: 'Tables', enabled: false, state: 'stopped', generation: 1, version: '1.0.0' })], releases: [release('tables', { name: 'Tables' })] })
await page.goto('/app/addons?tab=browse')
const row = page.locator('.cat-item', { hasText: 'Tables' })
await expect(row.getByRole('button')).toHaveCount(1)
await row.getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => state.log.dispatch.filter(d => d.action === 'enable').length).toBe(1)
expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
})
})
// What the Installed tab used to show is the Details of an add-on in My add-ons:
// the list read for an administrator and its installed record go together.
const details = (id, over = {}, installedOver = {}) => ({
apps: [app({ id, title: installedOver.title || id, admin: { canStart: false, canStop: true, managementUrl: `/app/addons?manage=${id}` }, ...over })],
installed: [installedAddon({ id, title: installedOver.title || id, ...installedOver })],
})
test.describe('Add-ons: installed', () => {
test('installed: health is separate from enabled and the details show trust, compatibility and storage', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, details('notes', { state: 'failed' }, { state: 'failed', restarts: 3, verification: 'sha256+sigstore', title: 'Notes' }))
await page.goto('/app/addons?manage=notes')
const pane = page.getByTestId('addon-detail')
await expect(pane.getByRole('heading', { name: 'Notes' })).toBeVisible()
await expect(pane.getByRole('img', { name: 'State: Failed' }).first()).toBeVisible()
const sheet = page.getByTestId('addon-manage')
await expect(sheet.getByText('Wanted state')).toBeVisible()
await expect(sheet.getByRole('img', { name: 'State: Failed' })).toBeVisible()
await expect(sheet.getByText('after 3 restart attempts')).toBeVisible()
await expect(sheet.getByText('Signed and digest checked')).toBeVisible()
// The trust section and the update section both name the installed digest.
await expect(sheet.getByText('sha256:0123456789ab').first()).toBeVisible()
await expect(sheet.getByTestId('manage-updates').getByText('sha256:0123456789ab')).toBeVisible()
await expect(sheet.locator('dt', { hasText: 'Host API' }).locator('xpath=following-sibling::dd[1]')).toHaveText('1-2')
await expect(sheet.getByText('12 KiB')).toBeVisible()
await expect(sheet.getByTestId('update-state')).toHaveAttribute('data-state', 'unknown')
await expect(sheet.getByText('not collected in this release')).toBeVisible()
await shoot(page, 'manage')
})
test('installed: an update is shown only from the owner update field and upgrade sends that version', async ({ page }) => {
await signIn(page, ADMIN)
const candidate = 'sha256:' + 'b'.repeat(64)
const admin = { canStart: false, canStop: true, managementUrl: '/app/addons?manage=notes' }
const state = await mockAddons(page, {
apps: [app({ update: 'update-pending', admin, title: 'Notes' }), app({ id: 'plain', title: 'Plain', admin: { ...admin, managementUrl: '/app/addons?manage=plain' } })],
installed: [
installedAddon({ update: { available: true, state: 'update-available', version: '1.3.0', digest: candidate, checkedAt: '2026-10-01T00:00:00Z' } }),
installedAddon({ id: 'plain', title: 'Plain', version: '9.9.9' }),
],
})
await page.goto('/app/addons')
await expect(page.locator('[data-testid="addon-tile"][data-app="notes"]').getByTestId('update-chip')).toHaveText('Update pending')
await expect(page.locator('[data-testid="addon-tile"][data-app="plain"]').getByTestId('update-chip')).toHaveCount(0)
await page.goto('/app/addons?manage=notes')
await page.getByTestId('addon-manage').getByRole('button', { name: 'Upgrade' }).click()
// The confirmation names the version and digest the owner resolved and
// nothing is sent until it is confirmed.
const dialog = page.getByTestId('upgrade-confirm')
await expect(dialog).toContainText('1.3.0')
await expect(dialog).toContainText('sha256:bbbbbbbbbbbb')
expect(state.log.upgrade).toHaveLength(0)
await page.getByRole('alertdialog').getByRole('button', { name: 'Upgrade' }).click()
await expect.poll(() => state.log.upgrade.length).toBe(1)
const sent = state.log.upgrade[0]
expect(sent.id).toBe('notes')
expect(sent.expectedDigest).toBe('sha256:0123456789abcdef0123456789abcdef')
expect(sent.candidateDigest).toBe(candidate)
expect(sent.operationId).toMatch(UUID)
expect(sent.confirm).toBe(sent.operationId)
expect(state.log.dispatch.filter(d => d.action === 'upgrade')).toHaveLength(0)
// An add-on without the field says the status is unknown and has no Upgrade.
await page.goto('/app/addons?manage=plain')
const sheet = page.getByTestId('addon-manage')
await expect(sheet.getByText('The update status is not known')).toBeVisible()
await expect(sheet.getByRole('button', { name: 'Upgrade' })).toHaveCount(0)
})
test('installed: removing warns that data is kept before anything is submitted', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page, details('notes', {}, { title: 'Notes' }))
await page.goto('/app/addons?detail=notes')
await page.getByTestId('addon-detail').getByRole('button', { name: 'Remove' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Remove Notes?')
await expect(dialog).toContainText('Its data is kept')
expect(state.log.dispatch.filter(d => d.action === 'uninstall')).toHaveLength(0)
await dialog.getByRole('button', { name: 'Cancel' }).click()
expect(state.log.dispatch.filter(d => d.action === 'uninstall')).toHaveLength(0)
await page.getByTestId('addon-detail').getByRole('button', { name: 'Remove' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Remove' }).click()
await expect.poll(() => state.log.dispatch.filter(d => d.action === 'uninstall').length).toBe(1)
})
test('installed: Escape cancels the remove dialog only, then closes the pane, and focus returns to the card', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, details('notes', {}, { title: 'Notes' }))
await page.goto('/app/addons')
const card = page.locator('[data-testid="addon-tile"][data-app="notes"] .ad-tile__open')
await card.click()
const pane = page.getByTestId('addon-detail')
await expect(pane).toBeVisible()
const remove = pane.getByRole('button', { name: 'Remove' })
await remove.focus()
await page.keyboard.press('Enter')
const dialog = page.getByRole('alertdialog')
await expect(dialog).toBeVisible()
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(pane).toBeVisible()
await expect(remove).toBeFocused()
await page.keyboard.press('Escape')
await expect(pane).toHaveCount(0)
await expect(card).toBeFocused()
})
test('installed: a headless add-on keeps its details, health and tools', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, details('indexer', { hasUI: false, mainPage: undefined, icon: undefined, tools: ['query', 'export'], toolCount: 2, state: 'running' }, { hasUI: false, mainPage: undefined, icon: undefined, tools: ['query', 'export'], toolCount: 2, state: 'running', title: 'indexer' }))
await page.goto('/app/addons?manage=indexer')
const sheet = page.getByTestId('addon-manage')
await expect(sheet.getByText('Headless, no pages')).toBeVisible()
await expect(sheet.getByText('2: query, export')).toBeVisible()
await expect(sheet.getByRole('img', { name: 'State: On' })).toBeVisible()
await expect(sheet.getByRole('link', { name: 'Open' })).toHaveCount(0)
})
test('installed: a refusal of the installed list leaves the pane without Details', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, { ...details('notes', {}, { title: 'Notes' }), dispatch: (body) => (body.action === 'addons-installed' ? fail(403, 'addon_error') : null) })
await page.goto('/app/addons?detail=notes')
await expect(page.getByTestId('addon-detail').getByRole('heading', { name: 'Notes' })).toBeVisible()
await expect(page.getByTestId('preview-source')).toHaveText('Not known')
await expect(page.getByTestId('addon-more')).toHaveCount(0)
})
test('installed: an old Installed address lands on My add-ons and the management link opens the details', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, details('notes', {}, { title: 'Notes' }))
await page.goto('/app/addons?tab=installed')
await expect(tab(page, 'My add-ons')).toHaveAttribute('aria-selected', 'true')
await expect(page.getByRole('tab', { name: 'Installed' })).toHaveCount(0)
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
await page.goto('/app/addons?manage=notes')
await expect(page.getByTestId('addon-more')).toHaveAttribute('open', '')
})
})
test.describe('Add-ons: libraries', () => {
test('libraries: a private library is saved by credential reference and never carries a secret', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page)
await page.goto('/app/addons?tab=libraries')
await page.getByRole('button', { name: 'Add a library' }).click()
const sheet = page.getByTestId('source-editor')
await expect(sheet.locator('input[type="password"]')).toHaveCount(0)
await sheet.getByLabel('Name').fill('Team library')
await expect(sheet.getByLabel('Identifier')).toHaveValue('team-library')
await sheet.getByLabel('Catalog URL').fill('https://team.example/releases.json')
// A pasted secret is refused before anything is sent.
await sheet.getByLabel('Credential reference (optional)').fill('ghp_Hunter2Hunter2Hunter2')
await sheet.getByRole('button', { name: 'Save' }).click()
await expect(sheet.getByText('does not look like a reference name')).toBeVisible()
expect(state.log.dispatch.filter(d => d.action === 'catalog-source-save')).toHaveLength(0)
await sheet.getByLabel('Credential reference (optional)').fill('team-token')
await sheet.getByRole('button', { name: 'Save' }).click()
await expect(page.locator('[data-source="team-library"]')).toBeVisible()
const saved = state.log.dispatch.filter(d => d.action === 'catalog-source-save')
expect(saved).toHaveLength(1)
expect(saved[0].arguments).toEqual({ id: 'team-library', name: 'Team library', url: 'https://team.example/releases.json', enabled: true, policy: { requireSignature: false }, authReference: 'team-token' })
expect(JSON.stringify(saved[0])).not.toMatch(/hunter2/i)
await expect(page.locator('[data-source="team-library"]')).toContainText('Credential reference: team-token')
})
test('libraries: removing a library says installed add-ons stay and leaves them installed', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page)
await page.goto('/app/addons?tab=libraries')
await page.locator('[data-source="main"]').getByRole('button', { name: 'Remove' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('stay installed and keep running')
await dialog.getByRole('button', { name: 'Remove' }).click()
await expect(page.locator('[data-source="main"]')).toHaveCount(0)
// A removal goes through the library route, the same one the other kinds use.
const removal = state.log.library.find(c => c.method === 'DELETE')
expect(removal.id).toBe('main')
await tab(page, 'My add-ons').click()
await expect(page.locator('[data-testid="addon-tile"][data-app="notes"]')).toBeVisible()
expect(state.installed).toHaveLength(1)
})
test('libraries: a refresh reports its result and a failed fetch is shown as failed', async ({ page }) => {
await signIn(page, ADMIN)
const failing = { id: 'broken', name: 'Broken', url: 'https://broken.example/releases.json', enabled: true, policy: { requireSignature: false }, refresh: { checkedAt: new Date().toISOString(), lastSuccess: '0001-01-01T00:00:00Z', count: 0, failed: true } }
const state = await mockAddons(page, {
sources: [failing],
dispatch: (body) => (body.action === 'catalog-source-refresh'
? { status: 503, contentType: 'application/json', body: JSON.stringify({ error: { type: 'outcome_unknown', message: 'unknown', operationId: body.operationId } }) }
: null),
})
await page.goto('/app/addons?tab=libraries')
const row = page.locator('[data-source="broken"]')
await expect(row.getByText('Last refresh failed')).toBeVisible()
await row.getByRole('button', { name: 'Refresh' }).click()
await expect(page.getByRole('alert').filter({ hasText: 'could not be confirmed' })).toBeVisible()
expect(state.log.dispatch.filter(d => d.action === 'catalog-source-refresh')).toHaveLength(1)
})
})
test.describe('Add-ons: access, keyboard, layout and language', () => {
test('keyboard: the tab list moves with the arrow keys and actions are reachable', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page, { apps: [app({ admin })] })
await page.goto('/app/addons')
await tab(page, 'My add-ons').focus()
await page.keyboard.press('ArrowRight')
await expect(tab(page, 'Find add-ons')).toBeFocused()
await expect(tab(page, 'Find add-ons')).toHaveAttribute('aria-selected', 'true')
await expect(page).toHaveURL(/tab=browse/)
await page.keyboard.press('End')
await expect(tab(page, 'Sources')).toHaveAttribute('aria-selected', 'true')
await page.keyboard.press('Home')
await expect(tab(page, 'My add-ons')).toHaveAttribute('aria-selected', 'true')
// The selected tab is the only one in the tab order.
await expect(tab(page, 'Find add-ons')).toHaveAttribute('tabindex', '-1')
// Stop is reachable and operable with the keyboard alone, in the detail sheet.
await page.goto('/app/addons?detail=notes')
const stop = detail(page, 'notes').getByRole('button', { name: 'Turn off' })
await stop.focus()
await expect(stop).toBeFocused()
await page.keyboard.press('Enter')
await expect.poll(() => state.log.stop.length).toBe(1)
})
test('narrow: at 400 px the apps page is one column without sideways scrolling', async ({ page }) => {
await page.setViewportSize({ width: 400, height: 800 })
await signIn(page, ADMIN)
await mockAddons(page, { apps: [app({ admin }), HEADLESS, app({ id: 'longer-identifier-for-wrapping', title: 'A fairly long add-on title that has to wrap', description: 'A description that is long enough to need several lines on a narrow screen without breaking out of its card.', admin })] })
await page.goto('/app/addons')
await expect(page.locator('[data-app="notes"]')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
const lefts = await page.locator('.ad-tile').evaluateAll(cards => cards.map(c => Math.round(c.getBoundingClientRect().left)))
expect(new Set(lefts).size).toBe(1)
await shoot(page, 'apps-narrow')
await page.goto('/app/addons?manage=notes')
await expect(page.getByTestId('addon-manage')).toBeVisible()
const sheetOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(sheetOverflow).toBeLessThanOrEqual(0)
await shoot(page, 'manage-narrow')
})
test('theme: the apps page follows the light and dark tokens', async ({ page }) => {
await signIn(page, MEMBER, { addons: true })
await mockAddons(page)
await page.addInitScript(() => localStorage.setItem('localai-theme', 'light'))
await page.goto('/app/addons')
await page.goto('/app/addons?detail=notes')
const read = () => page.locator('.ad-pane').first().evaluate(el => {
const css = getComputedStyle(el)
const chip = getComputedStyle(el.querySelector('.ad-chip'))
return { card: css.backgroundColor, chip: chip.color, chipBg: chip.backgroundColor }
})
const light = await read()
await shoot(page, 'apps-light')
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
await expect.poll(async () => (await read()).card).not.toBe(light.card)
const dark = await read()
expect(dark.chipBg).not.toBe('')
expect(dark.chip).not.toBe(dark.chipBg)
await shoot(page, 'apps-dark')
})
test('i18n: the page follows the interface language', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page)
await page.addInitScript(() => localStorage.setItem('localai-language', 'it'))
await page.goto('/app/addons')
await expect(page.getByRole('tab', { name: 'Trova componenti' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Fonti' })).toBeVisible()
await page.goto('/app/addons?detail=notes')
await expect(detail(page, 'notes').getByRole('img', { name: 'Stato: Attivo' })).toBeVisible()
})
test('hub: under the legacy switch the Add-ons tab appears only for an account that holds the feature', async ({ page }) => {
await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } }))
await signIn(page, MEMBER, { chat: true })
await mockAddons(page)
await page.goto('/app/build')
await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveCount(0)
await page.unroute('**/api/auth/status')
await signIn(page, MEMBER, { chat: true, addons: true })
await page.goto('/app/build')
await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveAttribute('href', '/app/addons')
await page.locator('.dk-hubtabs [data-hub-tab="addons"]').click()
await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveAttribute('aria-current', 'page')
})
})
test.describe('Add-ons: shared catalog components keep the backends gallery', () => {
test('regression: the backends catalog still searches, pages and shows its empty state', async ({ page }) => {
const backends = Array.from({ length: 70 }, (_, i) => ({ name: `backend-${String(i).padStart(2, '0')}`, description: 'A backend', installed: false, tags: ['chat'] }))
await page.route('**/api/backends*', route => {
const term = new URL(route.request().url()).searchParams.get('term') || ''
return route.fulfill({ json: { backends: term === 'nothing-here' ? [] : backends } })
})
await page.goto('/app/backends')
await expect(page.locator('[data-entity="backend-00"]')).toBeVisible({ timeout: 10_000 })
const pager = page.locator('.bk-pager')
await expect(pager).toContainText('1 / 2')
await expect(pager).toHaveClass(/cat-pager/)
await pager.getByRole('button').last().click()
await expect(pager).toContainText('2 / 2')
await expect(page.locator('[data-entity="backend-60"]')).toBeVisible()
await expect(page.locator('.bk-search.dk-input-icon input')).toBeVisible()
await page.getByPlaceholder(/search backends/i).fill('nothing-here')
await expect(page.locator('.dk-empty')).toBeVisible()
await expect(page.locator('.dk-empty .dk-empty-title')).toBeVisible()
})
})