feat(ui): show the kind of library entries and install backends from them

The Browse tab lists the entries of every kind this host reads and marks
each one as an add-on or a backend. A backend installs through the
backend route and not through the add-on lifecycle: the confirmation
says that the backend gallery is not changed and that a backend runs
native code, and a refusal of the host, such as a name installed from
another origin, is shown with its own reason. The details give the kind,
the alias and the signer pattern.

The library editor can name the signer with a pattern. Without this a
save would drop the pattern of a backend library. The Libraries tab
reports the entries a refresh skipped because their kind is not read.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-21 20:15:10 +00:00
1 parent 1d8d3e3421
commit 49b2b151b7
7 files changed
+253 -20

No files matched your search

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test'
import { ADMIN, release, sessionFor, mockAddons } from './addons-fixtures.js'
// Backends in the library (phase 1): an entry has a kind, the Browse tab shows
// it, and a backend installs through the backend route, not the add-on
// lifecycle. The server is the authority, so these specs drive the page
// against a stand-in and assert what the page sent.
const POLICY = { requireSignature: true, issuer: 'https://token.actions.githubusercontent.com', identityRegex: '^https://github\\.com/o/r/\\.github/workflows/w\\.yml@refs/(heads/master|tags/.+)$' }
const backend = (over = {}) => release('cpu-local-store', {
kind: 'backend', name: 'cpu-local-store', alias: 'local-store', description: 'A vector store.', version: '0.0.0-latest.0a1056fd8633',
sourceId: 'official-backends', artifact: 'oci://registry.example/backends:latest-cpu-local-store', policy: POLICY, ...over,
})
async function signIn(page) {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) }))
}
// The backend routes of the host. `installed` is the mutable list GET /backends answers.
async function mockBackends(page, { installed = [], install } = {}) {
const state = { installed: [...installed], installs: [] }
await page.route(/\/backends$/, route => route.fulfill({ json: state.installed }))
await page.route(/\/backends\/library\/install$/, route => {
const body = route.request().postDataJSON()
state.installs.push(body)
if (install) {
const custom = install(body)
if (custom) return route.fulfill(custom)
}
state.installed.push({ Name: body.name, Metadata: { library: body.sourceId } })
return route.fulfill({ json: { status: 'installed', name: body.name, sourceId: body.sourceId } })
})
return state
}
test.describe('Library backends', () => {
test('browse: an entry shows its kind, and add-ons are still add-ons', async ({ page }) => {
await signIn(page)
const state = await mockAddons(page, { installed: [], releases: [release('tables'), backend()] })
await mockBackends(page)
await page.goto('/app/addons?tab=browse')
await expect(page.locator('.cat-item')).toHaveCount(2)
await expect(page.locator('.cat-item', { hasText: 'Tables' }).getByTestId('entry-kind')).toHaveText('Add-on')
const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
await expect(row.getByTestId('entry-kind')).toHaveText('Backend')
await expect(row.getByTestId('entry-kind')).toHaveAttribute('data-kind', 'backend')
// The page asks for every kind this host reads.
expect(state.log.dispatch.find(d => d.action === 'catalog-search').arguments.kind).toBe('all')
})
test('browse: a backend installs through the backend route and never through the add-on lifecycle', async ({ page }) => {
await signIn(page)
const state = await mockAddons(page, { installed: [], releases: [backend()] })
const backends = await mockBackends(page)
await page.goto('/app/addons?tab=browse')
const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
await row.getByRole('button', { name: 'Install' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Install cpu-local-store?')
await expect(dialog).toContainText('backend gallery is not changed')
await expect(dialog).toContainText('native code')
expect(backends.installs).toHaveLength(0)
await dialog.getByRole('button', { name: 'Install' }).click()
await expect(row.getByText('Installed as a backend on this node')).toBeVisible()
expect(backends.installs).toEqual([{ sourceId: 'official-backends', name: 'cpu-local-store', version: '0.0.0-latest.0a1056fd8633' }])
expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
// Once installed the entry says so and offers no second install.
await expect(row.getByText('Installed', { exact: true })).toBeVisible()
await expect(row.getByRole('button', { name: 'Install' })).toBeDisabled()
})
test('browse: a refusal of the host is shown with its own reason', async ({ page }) => {
await signIn(page)
await mockAddons(page, { installed: [], releases: [backend()] })
await mockBackends(page, {
install: () => ({ status: 409, json: { error: { type: 'backend_library', code: 409, message: 'backend is already installed from another origin: "cpu-local-store"' } } }),
})
await page.goto('/app/addons?tab=browse')
const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
await row.getByRole('button', { name: 'Install' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Install' }).click()
await expect(row.getByRole('alert')).toContainText('The backend was not installed.')
await expect(row.getByRole('alert')).toContainText('already installed from another origin')
await expect(row.getByRole('button', { name: 'Install' })).toBeEnabled()
})
test('browse: the details give the kind, the alias and the signer pattern', async ({ page }) => {
await signIn(page)
await mockAddons(page, { installed: [], releases: [backend()] })
await mockBackends(page)
await page.goto('/app/addons?tab=browse')
await page.locator('.cat-item', { hasText: 'cpu-local-store' }).getByRole('button', { name: 'Details' }).click()
await expect(page.getByTestId('details-kind')).toHaveText('Backend')
await expect(page.getByText('local-store', { exact: true })).toBeVisible()
await expect(page.getByText('workflows/w', { exact: false })).toBeVisible()
})
test('libraries: a signer pattern is edited and saved without an exact identity, and skipped entries are reported', async ({ page }) => {
await signIn(page)
const state = await mockAddons(page, {
sources: [{ id: 'official-backends', name: 'Official backends', url: 'https://catalog.example/backends.yaml', enabled: true, policy: POLICY, refresh: { checkedAt: new Date().toISOString(), lastSuccess: new Date().toISOString(), count: 1, skipped: 2, skippedKinds: ['model'], failed: false } }],
})
await mockBackends(page)
await page.goto('/app/addons?tab=libraries')
await expect(page.getByTestId('skipped-kinds')).toContainText('2 entries skipped: kind model')
await page.getByRole('button', { name: 'Edit' }).click()
const pattern = page.getByLabel('Signer identity pattern (optional)')
await expect(pattern).toHaveValue(POLICY.identityRegex)
await page.getByRole('button', { name: 'Save' }).click()
await expect.poll(() => state.log.dispatch.filter(d => d.action === 'catalog-source-save').length).toBe(1)
const saved = state.log.dispatch.find(d => d.action === 'catalog-source-save').arguments
expect(saved.policy).toEqual(POLICY)
})
})
@@ -67,7 +67,7 @@
"details": "Details",
"detailsLead": "Library entry for {{id}}",
"emptyBody": "Add a library on the Libraries tab, then refresh it.",
"emptyTitle": "No add-ons found",
"emptyTitle": "No library entries found",
"fromLibrary": "from {{library}}",
"install": "Install",
"installedDetail": "Version {{version}}, generation {{generation}}",
@@ -76,7 +76,18 @@
"installedSame": "Installed {{version}}",
"installedState": "Installed",
"installing": "Installing",
"search": "Search the libraries"
"search": "Search the libraries",
"kind": {
"addon": "Add-on",
"backend": "Backend"
},
"installedBackend": "Installed",
"installedBackendNote": "Installed as a backend on this node. Models can use it now.",
"confirmBackendBody": "Version {{version}} of the backend from the library {{library}} will be downloaded, checked against its digest and signature, and installed on this node. The backend gallery is not changed.",
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind"
},
"catalog": {
"allLibraries": "All libraries",
@@ -156,7 +167,8 @@
"required": "Required when a signature is required.",
"url": "Enter an http or https address.",
"urlCredentials": "The address must not contain a user name or password.",
"urlQuery": "The address must not contain a query string or fragment."
"urlQuery": "The address must not contain a query string or fragment.",
"either": "Use the exact identity or the pattern, not both."
},
"fields": {
"enabled": "Enabled",
@@ -166,12 +178,14 @@
"name": "Name",
"reference": "Credential reference (optional)",
"requireSignature": "Require a signature",
"url": "Catalog URL"
"url": "Catalog URL",
"identityRegex": "Signer identity pattern (optional)"
},
"hints": {
"id": "Lowercase letters, digits and hyphens. It cannot change later.",
"reference": "The name of a credential the owner configured. Never paste a token or password here.",
"url": "An https address without credentials or a query string."
"url": "An https address without credentials or a query string.",
"identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify."
},
"lastRefresh": "Refreshed {{when}}, {{count}} entries",
"lead": "A library is a catalog document that lists add-ons. Credentials are never typed here: a private library names a credential the owner configured.",
@@ -187,7 +201,9 @@
"removeTitle": "Remove {{name}}?",
"removed": "Removed {{name}}.",
"save": "Save",
"saved": "Saved {{name}}."
"saved": "Saved {{name}}.",
"skipped_one": "{{count}} entry skipped: kind {{kinds}} is not read by this version",
"skipped_other": "{{count}} entries skipped: kind {{kinds}} is not read by this version"
},
"manage": {
"api": "Add-on API",
@@ -20,6 +20,10 @@ export default function BrowseView({ api, adapter }) {
const { t } = useTranslation('addons')
const [params, setParams] = useSearchParams()
const [installed, setInstalled] = useState({})
// Backends are installed by the backend code of the host, not by the add-on
// owner, so they have their own installed list and their own install state.
const [installedBackends, setInstalledBackends] = useState({})
const [backendWork, setBackendWork] = useState({})
const [reload, setReload] = useState(0)
const [confirm, setConfirm] = useState(null)
const [details, setDetails] = useState(null)
@@ -43,9 +47,31 @@ export default function BrowseView({ api, adapter }) {
return () => ctl.abort()
}, [api, reload])
useEffect(() => {
const ctl = new AbortController()
api.backends.installed(ctl.signal)
.then(list => setInstalledBackends(Object.fromEntries(list.map(b => [b.id, b]))))
.catch(() => { if (!ctl.signal.aborted) setInstalledBackends({}) })
return () => ctl.abort()
}, [api, reload])
const actions = useAddonActions(api, { onSettled: () => setReload(n => n + 1) })
const installBackend = async (entry) => {
setConfirm(null)
const key = `install:${entry.key}`
setBackendWork(w => ({ ...w, [key]: { phase: 'working' } }))
try {
await api.backends.install({ sourceId: entry.sourceId, name: entry.id, version: entry.version })
setBackendWork(w => ({ ...w, [key]: { phase: 'done' } }))
} catch (error) {
setBackendWork(w => ({ ...w, [key]: { phase: 'failed', error } }))
}
setReload(n => n + 1)
}
const install = (entry) => {
if (entry.kind === 'backend') return installBackend(entry)
setConfirm(null)
actions.run(`install:${entry.key}`, {
addonId: entry.id,
@@ -57,11 +83,13 @@ export default function BrowseView({ api, adapter }) {
}
const renderEntry = (entry) => {
const have = installed[entry.id]
const isBackend = entry.kind === 'backend'
const have = isBackend ? installedBackends[entry.id] : installed[entry.id]
const key = `install:${entry.key}`
const st = actions.state[key]
const pending = Boolean(actions.unknown[entry.id])
const working = st?.phase === 'working'
const bw = isBackend ? backendWork[key] : null
const st = isBackend ? null : actions.state[key]
const pending = !isBackend && Boolean(actions.unknown[entry.id])
const working = isBackend ? bw?.phase === 'working' : st?.phase === 'working'
return (
<article className="ad-entry" aria-labelledby={`entry-${entry.key}`}>
<AppIcon src="" />
@@ -76,19 +104,27 @@ export default function BrowseView({ api, adapter }) {
</p>
{entry.description && <p className="ad-entry__text">{entry.description}</p>}
<div className="ad-facts">
<Fact icon={isBackend ? 'cpu' : 'puzzle'}><span data-testid="entry-kind" data-kind={entry.kind}>{t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })}</span></Fact>
<Fact icon="cpu">{entry.platform.os}/{entry.platform.arch}</Fact>
<Fact icon={entry.policy.requireSignature ? 'shield' : 'info'} tone={entry.policy.requireSignature ? 'ok' : undefined}>
{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
</Fact>
{entry.channel && <Fact icon="tag">{entry.channel}</Fact>}
{have && <Fact icon="check-circle" tone="ok">{have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}</Fact>}
{have && updateView(have.update).state === 'update-available' && <Fact icon="arrow-up" tone="warn">{t('updates.badge.available', { version: updateView(have.update).version })}</Fact>}
{isBackend && have && <Fact icon="check-circle" tone="ok">{t('browse.installedBackend')}</Fact>}
{!isBackend && have && <Fact icon="check-circle" tone="ok">{have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}</Fact>}
{!isBackend && have && updateView(have.update).state === 'update-available' && <Fact icon="arrow-up" tone="warn">{t('updates.badge.available', { version: updateView(have.update).version })}</Fact>}
</div>
{pending && <UnknownNotice addonId={entry.id} actions={actions} />}
{!pending && st && !working && st.outcome !== 'completed' && (
<p className="ad-entry__note" data-tone="error" role="alert">{describeOutcome(st.outcome, t, st.error)}</p>
)}
{st && st.outcome === 'completed' && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedNotEnabled')}</p>}
{bw?.phase === 'failed' && (
<p className="ad-entry__note" data-tone="error" role="alert">
{t('browse.backendFailed')} {bw.error?.status >= 400 && bw.error.message && bw.error.message !== bw.error.kind ? bw.error.message : describeError(bw.error, t)}
</p>
)}
{bw?.phase === 'done' && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedBackendNote')}</p>}
</div>
<div className="ad-entry__acts">
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => setDetails(entry)}>
@@ -121,12 +157,12 @@ export default function BrowseView({ api, adapter }) {
title={confirm ? t('browse.confirmTitle', { name: confirm.title }) : ''}
message={confirm ? (
<div className="ad-confirm">
<p>{t('browse.confirmBody', { version: confirm.version, library: confirm.sourceId })}</p>
<p>{confirm.kind === 'backend' ? t('browse.confirmBackendBody', { version: confirm.version, library: confirm.sourceId }) : t('browse.confirmBody', { version: confirm.version, library: confirm.sourceId })}</p>
<dl className="dk-kv">
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(confirm.digest)}</dd>
<dt>{t('trust.verification')}</dt><dd>{confirm.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
</dl>
<p>{t('browse.confirmNotEnabled')}</p>
<p>{confirm.kind === 'backend' ? t('browse.confirmBackendNote') : t('browse.confirmNotEnabled')}</p>
</div>
) : null}
confirmLabel={t('browse.install')}
@@ -152,12 +188,14 @@ function EntryDetails({ adapter, entry, onClose }) {
<SideSheet title={e.title} description={t('browse.detailsLead', { id: e.id })} onClose={onClose} closeLabel={t('catalog.close')} labelId="entry-details-title">
{state.status === 'error' && <p className="op-error" role="alert">{describeError(state.error, t)}</p>}
<dl className="dk-kv ad-kv">
<dt>{t('browse.kindLabel')}</dt><dd data-testid="details-kind">{t(`browse.kind.${e.kind}`, { defaultValue: e.kind })}</dd>
<dt>{t('trust.version')}</dt><dd>{e.version}</dd>
{e.alias && (<><dt>{t('browse.alias')}</dt><dd className="dk-mono">{e.alias}</dd></>)}
<dt>{t('trust.library')}</dt><dd>{e.sourceId}</dd>
{e.channel && (<><dt>{t('trust.channel')}</dt><dd>{e.channel}</dd></>)}
<dt>{t('trust.platform')}</dt><dd>{e.platform.os}/{e.platform.arch}</dd>
<dt>{t('trust.verification')}</dt><dd>{e.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
{e.policy.requireSignature && (<><dt>{t('trust.issuer')}</dt><dd className="dk-mono ad-wrap">{e.policy.issuer}</dd><dt>{t('trust.identity')}</dt><dd className="dk-mono ad-wrap">{e.policy.identity}</dd></>)}
{e.policy.requireSignature && (<><dt>{t('trust.issuer')}</dt><dd className="dk-mono ad-wrap">{e.policy.issuer}</dd><dt>{t('trust.identity')}</dt><dd className="dk-mono ad-wrap">{e.policy.identity || e.policy.identityRegex}</dd></>)}
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap">{e.digest}</dd>
{e.installed && (<><dt>{t('browse.installedState')}</dt><dd>{t('browse.installedDetail', { version: e.installed.version, generation: e.installed.generation })}</dd></>)}
</dl>
@@ -10,7 +10,7 @@ import { createOperationBook, draftToSource, slugify, sourceToDraft, validateSou
import './catalog.css'
import '../addons/addons.css'
const BLANK = { id: '', name: '', url: '', enabled: true, requireSignature: false, issuer: '', identity: '', authReference: '', allowLoopbackHTTP: false }
const BLANK = { id: '', name: '', url: '', enabled: true, requireSignature: false, issuer: '', identity: '', identityRegex: '', authReference: '', allowLoopbackHTTP: false }
function seen(iso) {
const d = new Date(iso)
@@ -132,6 +132,9 @@ export default function SourcesView({ adapter, describeError, onChanged }) {
{source.authReference && <span className="ad-fact"><Icon name="key" aria-hidden="true" />{t('libraries.reference', { name: source.authReference })}</span>}
{source.refresh?.failed && <span className="ad-fact" data-tone="error">{t('libraries.refreshFailed')}</span>}
{!source.refresh && source.enabled && <span className="ad-fact">{t('libraries.notRefreshed')}</span>}
{source.refresh?.skipped > 0 && !source.refresh.failed && (
<span className="ad-fact" data-tone="warn" data-testid="skipped-kinds">{t('libraries.skipped', { count: source.refresh.skipped, kinds: (source.refresh.skippedKinds || []).join(', ') })}</span>
)}
{last && !source.refresh.failed && <span className="ad-fact">{t('libraries.lastRefresh', { when: last.toLocaleString(i18n.language), count: source.refresh.count })}</span>}
</div>
</div>
@@ -214,6 +217,7 @@ function SourceEditor({ draft, onSave, onClose, saving, t }) {
</label>
{form.requireSignature && field('issuer', t('libraries.fields.issuer'), { autoComplete: 'off' })}
{form.requireSignature && field('identity', t('libraries.fields.identity'), { autoComplete: 'off' })}
{form.requireSignature && field('identityRegex', t('libraries.fields.identityRegex'), { autoComplete: 'off', spellCheck: false }, t('libraries.hints.identityRegex'))}
{field('authReference', t('libraries.fields.reference'), { autoComplete: 'off', spellCheck: false }, t('libraries.hints.reference'))}
</form>
</SideSheet>
+10 -2
View File
@@ -142,7 +142,11 @@ export function validateSourceDraft(draft = {}) {
if (ref && !REFERENCE.test(ref)) errors.authReference = 'reference'
if (draft.requireSignature) {
if (!String(draft.issuer || '').trim()) errors.issuer = 'required'
if (!String(draft.identity || '').trim()) errors.identity = 'required'
// A signer is named exactly or by a pattern (backend libraries), not both.
const exact = String(draft.identity || '').trim()
const pattern = String(draft.identityRegex || '').trim()
if (!exact && !pattern) errors.identity = 'required'
else if (exact && pattern) errors.identityRegex = 'either'
}
return errors
}
@@ -159,7 +163,10 @@ export function draftToSource(draft) {
}
if (draft.requireSignature) {
source.policy.issuer = String(draft.issuer).trim()
source.policy.identity = String(draft.identity).trim()
const exact = String(draft.identity || '').trim()
const pattern = String(draft.identityRegex || '').trim()
if (exact) source.policy.identity = exact
if (pattern) source.policy.identityRegex = pattern
}
const ref = String(draft.authReference || '').trim()
if (ref) source.authReference = ref
@@ -177,6 +184,7 @@ export function sourceToDraft(source = {}) {
requireSignature: Boolean(source.policy?.requireSignature),
issuer: source.policy?.issuer || '',
identity: source.policy?.identity || '',
identityRegex: source.policy?.identityRegex || '',
authReference: source.authReference || '',
allowLoopbackHTTP: Boolean(source.allowLoopbackHTTP),
}
+17 -1
View File
@@ -128,6 +128,19 @@ export function createAddonsApi({ fetch: fetchImpl, base = p => p } = {}) {
prune(id, { operationId }, signal) { return api.recovery.mutate(id, 'recovery/prune', { operationId }, signal) },
purge(id, body, signal) { return api.recovery.mutate(id, 'purge', body, signal) },
},
// Backends of the libraries. The library entry is resolved by the server;
// the request names the entry and nothing else. The install runs inside
// the request and answers when the backend is in place.
backends: {
async installed(signal) {
const out = await call('/backends', { signal })
const list = Array.isArray(out) ? out : (Array.isArray(out?.backends) ? out.backends : [])
return list.map(b => ({ id: b.Name || b.name || '', library: b.Metadata?.library || '' })).filter(b => b.id)
},
install({ sourceId, name, version }, signal) {
return call('/backends/library/install', { method: 'POST', body: { sourceId, name, version }, signal })
},
},
// Update detection and the explicit upgrade. The status is the owner's
// answer and the page never derives it. Check and upgrade are the
// administrator's own action: the operation ID is also the confirmation
@@ -174,6 +187,9 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) {
description: release.description || release.ui?.description || '',
version: release.version,
sourceId: release.sourceId,
// The kind of the entry: addon when the library does not say.
kind: release.kind || 'addon',
alias: release.alias || '',
channel: release.channel || '',
digest: release.digest || '',
platform: { os: release.os, arch: release.arch },
@@ -188,7 +204,7 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) {
const payload = await api.dispatch({
operationId: newOperationId(),
action: 'catalog-search',
args: { text: query?.text || '', sourceId: query?.sourceId || '', offset, limit: pageSize },
args: { text: query?.text || '', kind: 'all', sourceId: query?.sourceId || '', offset, limit: pageSize },
}, signal)
const releases = Array.isArray(payload?.releases) ? payload.releases : []
return { entries: releases.map(toEntry), total: Number(payload?.total) || 0, offset }
+38 -1
View File
@@ -153,11 +153,12 @@ test('the api sends one dispatch shape and classifies refusals', async () => {
assert.equal(calls[0].init.method, 'POST')
assert.equal(calls[0].init.credentials, 'same-origin')
const sent = JSON.parse(calls[0].init.body)
assert.deepEqual(sent.arguments, { text: 'no', sourceId: 'main', offset: 10, limit: 10 })
assert.deepEqual(sent.arguments, { text: 'no', kind: 'all', sourceId: 'main', offset: 10, limit: 10 })
assert.equal(sent.action, 'catalog-search')
assert.ok(sent.operationId)
assert.equal(page.total, 31)
assert.equal(page.entries[0].key, 'main/notes/1.0.0')
assert.equal(page.entries[0].kind, 'addon')
await assert.rejects(adapter.listSources(), e => e instanceof AddonsError && e.kind === 'denied')
await assert.rejects(
adapter.submitLifecycle({ operationId: 'op-1', action: 'install', addonId: 'notes', sourceId: 'main', version: '1.0.0' }),
@@ -278,3 +279,39 @@ test('a refusal shows the server reason and falls back to the generic text', ()
// Other kinds keep their own words.
assert.equal(describeError(new AddonsError(classifyResponse(401, { error: { message: 'x' } })), t), 'T:errors.auth')
})
test('a backend entry keeps its kind and installs through the backend route', async () => {
const calls = []
const api = createAddonsApi({
fetch: async (url, init) => {
calls.push({ url, init })
const body = init.body ? JSON.parse(init.body) : null
if (body?.action === 'catalog-search') return json(200, { payload: { releases: [{ kind: 'backend', addonId: 'cpu-local-store', alias: 'local-store', name: 'cpu-local-store', version: '0.0.0-latest.aaaaaaaaaaaa', sourceId: 'official-backends', os: 'linux', arch: 'amd64', digest: 'sha256:a' }], total: 1 } })
if (url === '/backends/library/install') return json(409, { error: { type: 'backend_library', message: 'backend is already installed from another origin: "cpu-local-store"' } })
if (url === '/backends') return json(200, [{ Name: 'cpu-local-store', Metadata: { library: 'official-backends' } }, { Name: 'llama-cpp' }])
return json(200, {})
},
})
const adapter = createAddonsCatalogAdapter(api, { pageSize: 10 })
const page = await adapter.search({})
assert.equal(page.entries[0].kind, 'backend')
assert.equal(page.entries[0].alias, 'local-store')
assert.equal(page.entries[0].key, 'official-backends/cpu-local-store/0.0.0-latest.aaaaaaaaaaaa')
assert.deepEqual(await api.backends.installed(), [{ id: 'cpu-local-store', library: 'official-backends' }, { id: 'llama-cpp', library: '' }])
await assert.rejects(
api.backends.install({ sourceId: 'official-backends', name: 'cpu-local-store', version: 'v' }),
e => e instanceof AddonsError && e.kind === 'unavailable' && e.status === 409 && /another origin/.test(e.message),
)
const sent = JSON.parse(calls.at(-1).init.body)
assert.deepEqual(sent, { sourceId: 'official-backends', name: 'cpu-local-store', version: 'v' })
})
test('a library signer is named exactly or by a pattern, never both', () => {
const ok = { id: 'b', name: 'B', url: 'https://library.example/i.yaml', enabled: true, requireSignature: true, issuer: 'i' }
assert.equal(validateSourceDraft({ ...ok }).identity, 'required')
assert.deepEqual(validateSourceDraft({ ...ok, identityRegex: '^a$' }), {})
assert.equal(validateSourceDraft({ ...ok, identity: 'x', identityRegex: '^a$' }).identityRegex, 'either')
const wire = draftToSource({ ...ok, identityRegex: ' ^a$ ' })
assert.deepEqual(wire.policy, { requireSignature: true, issuer: 'i', identityRegex: '^a$' })
assert.equal(sourceToDraft({ policy: { requireSignature: true, issuer: 'i', identityRegex: '^a$' } }).identityRegex, '^a$')
})