mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
fix(ui): keep backends off the add-on browse list (#12878)
The add-on Browse list showed backend entries next to add-ons, with their own install flow. Backends belong on the Backends page, where the library chips already cover them. Ask the library for add-ons only and drop any entry that still says it is a backend. Entries with no kind stay add-ons. Remove the backend-only install flow from the Browse view. 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>
This commit is contained in:
1 parent
840fa3cc00
commit
9c4a8b2a87
4 files changed
+49
-173
No files matched your search
@@ -1,9 +1,8 @@
|
||||
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
|
||||
// Backends in the library: an entry has a kind, the add-on Browse tab lists
|
||||
// add-ons only, and backends are handled on the Backends page. 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/.+)$' }
|
||||
@@ -46,89 +45,18 @@ async function mockBackends(page, { installed = [], phases = ['resolving', 'size
|
||||
}
|
||||
|
||||
test.describe('Library backends', () => {
|
||||
test('browse: an entry shows its kind, and add-ons are still add-ons', async ({ page }) => {
|
||||
test('browse: a backend entry is not listed, and add-ons are', 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)
|
||||
// A backend says what it is on the entry; an add-on is the default and says it under Details.
|
||||
const tables = page.locator('.cat-item', { hasText: 'Tables' })
|
||||
await expect(tables.getByTestId('entry-kind')).toHaveCount(0)
|
||||
await tables.getByText('Details', { exact: true }).click()
|
||||
await expect(tables.getByTestId('details-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()
|
||||
// The steps show while the job runs: a signed library lists the signature step.
|
||||
const steps = row.getByTestId('install-steps')
|
||||
await expect(steps.locator('[data-step]')).toHaveCount(5)
|
||||
await expect(steps.locator('[data-step="fetching"]')).toHaveAttribute('data-state', /active|done/)
|
||||
await expect(row.getByText('Installed as a backend on this node')).toBeVisible()
|
||||
await expect(steps).toHaveCount(0)
|
||||
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' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
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, {
|
||||
phases: ['resolving'],
|
||||
fail: '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()
|
||||
// The step that failed stays marked.
|
||||
await expect(row.locator('[data-step="resolving"]')).toHaveAttribute('data-state', 'failed')
|
||||
await expect(row.locator('[data-step="fetching"]')).toHaveAttribute('data-state', 'pending')
|
||||
})
|
||||
|
||||
test('browse: a request the host refuses is shown with its reason', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await mockAddons(page, { installed: [], releases: [backend()] })
|
||||
await mockBackends(page, { refuse: { status: 409, json: { error: { type: 'backend_library', code: 409, message: 'this backend is already being installed' } } } })
|
||||
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('already being installed')
|
||||
})
|
||||
|
||||
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' }).getByText('Details', { exact: true }).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()
|
||||
// Backends belong to the Backends page: only the add-on is listed.
|
||||
await expect(page.locator('.cat-item')).toHaveCount(1)
|
||||
await expect(page.locator('.cat-item', { hasText: 'Tables' })).toBeVisible()
|
||||
await expect(page.locator('.cat-item', { hasText: 'cpu-local-store' })).toHaveCount(0)
|
||||
await expect(page.getByTestId('entry-kind')).toHaveCount(0)
|
||||
// The page asks for add-ons only.
|
||||
expect(state.log.dispatch.find(d => d.action === 'catalog-search').arguments.kind).toBe('addon')
|
||||
})
|
||||
|
||||
test('libraries: a signer pattern is edited and saved without an exact identity, and skipped entries are reported', async ({ page }) => {
|
||||
|
||||
@@ -3,31 +3,24 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../Icon'
|
||||
import ConfirmDialog from '../ConfirmDialog'
|
||||
import CatalogView from '../catalog/CatalogView'
|
||||
import { paramsToQuery, queryToParams } from '../catalog/catalogState'
|
||||
import { AppIcon, Fact, InstallSteps, UnknownNotice } from './AddonBits'
|
||||
import { AppIcon, Fact, UnknownNotice } from './AddonBits'
|
||||
import FlowStatus from './InstallFlow'
|
||||
import InstallDialog from './InstallDialog'
|
||||
import { shortDigest } from './addonState'
|
||||
import { updateView } from './updateState'
|
||||
import { describeError, describeOutcome } from './errors'
|
||||
import { useAddonActions } from './useAddonActions'
|
||||
|
||||
// Library entries for this host. An add-on entry has one button, Install and
|
||||
// turn on: one confirmation, then the install and the turn on run one after
|
||||
// the other. An add-on that is installed but off offers Turn on. A backend has
|
||||
// no on and off, so its button stays Install.
|
||||
// the other. An add-on that is installed but off offers Turn on. Backends are
|
||||
// not listed here: they belong to the Backends page.
|
||||
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 [dialog, setDialog] = useState(null)
|
||||
const query = paramsToQuery(k => params.get(k))
|
||||
|
||||
@@ -49,47 +42,17 @@ 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), adapter })
|
||||
|
||||
const installBackend = async (entry) => {
|
||||
setConfirm(null)
|
||||
const key = `install:${entry.key}`
|
||||
setBackendWork(w => ({ ...w, [key]: { phase: 'working', job: { phase: 'resolving' } } }))
|
||||
try {
|
||||
const job = await api.backends.install(
|
||||
{ sourceId: entry.sourceId, name: entry.id, version: entry.version }, undefined,
|
||||
{ onStatus: status => setBackendWork(w => ({ ...w, [key]: { phase: 'working', job: status } })) },
|
||||
)
|
||||
setBackendWork(w => ({ ...w, [key]: { phase: 'done', job } }))
|
||||
} catch (error) {
|
||||
setBackendWork(w => ({ ...w, [key]: { phase: 'failed', error, job: { phase: error.phase, processed: true, error: error.message } } }))
|
||||
}
|
||||
setReload(n => n + 1)
|
||||
}
|
||||
|
||||
// A backend has its own job and its own confirmation. An add-on goes through
|
||||
// the one dialog of Install and turn on, which stays open for the steps.
|
||||
const install = (entry) => installBackend(entry)
|
||||
const openInstall = (entry) => { if (entry.kind === 'backend') setConfirm(entry); else setDialog(entry) }
|
||||
const openInstall = (entry) => setDialog(entry)
|
||||
|
||||
const renderEntry = (entry) => {
|
||||
const isBackend = entry.kind === 'backend'
|
||||
const have = isBackend ? installedBackends[entry.id] : installed[entry.id]
|
||||
const key = `install:${entry.key}`
|
||||
const bw = isBackend ? backendWork[key] : null
|
||||
const flow = isBackend ? null : actions.flows[`flow:${entry.key}`]
|
||||
const pending = !isBackend && Boolean(actions.unknown[entry.id])
|
||||
const working = isBackend ? bw?.phase === 'working' : ['install', 'turn-on'].includes(flow?.step)
|
||||
const have = installed[entry.id]
|
||||
const flow = actions.flows[`flow:${entry.key}`]
|
||||
const pending = Boolean(actions.unknown[entry.id])
|
||||
const working = ['install', 'turn-on'].includes(flow?.step)
|
||||
const addsPage = Boolean(entry.ui && entry.ui.mainPage)
|
||||
const off = !isBackend && have && have.enabled === false
|
||||
const off = have && have.enabled === false
|
||||
return (
|
||||
<article className="ad-entry" aria-labelledby={`entry-${entry.key}`}>
|
||||
<AppIcon src="" />
|
||||
@@ -104,31 +67,17 @@ export default function BrowseView({ api, adapter }) {
|
||||
</p>
|
||||
{entry.description && <p className="ad-entry__text">{entry.description}</p>}
|
||||
<div className="ad-facts">
|
||||
{isBackend && <Fact icon="cpu"><span data-testid="entry-kind" data-kind={entry.kind}>{t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })}</span></Fact>}
|
||||
{!isBackend && addsPage && <Fact icon="file"><span data-testid="entry-adds">{t('card.addsPage')}</span></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>}
|
||||
{addsPage && <Fact icon="file"><span data-testid="entry-adds">{t('card.addsPage')}</span></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>}
|
||||
</div>
|
||||
<EntryFacts entry={entry} have={have} isBackend={isBackend} />
|
||||
<EntryFacts entry={entry} have={have} />
|
||||
{pending && <UnknownNotice addonId={entry.id} actions={actions} />}
|
||||
{!pending && !(dialog && dialog.key === entry.key) && <FlowStatus flow={flow} name={entry.title} onRetry={flow?.step === 'failed' ? () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' }) : undefined} />}
|
||||
{flow && flow.step === 'done' && !(dialog && dialog.key === entry.key) && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedOn', { name: entry.title })}</p>}
|
||||
{bw && bw.phase !== 'done' && <InstallSteps job={bw.job} signed={entry.policy.requireSignature} />}
|
||||
{bw?.phase === 'failed' && (
|
||||
<p className="ad-entry__note" data-tone="error" role="alert">
|
||||
{t('browse.backendFailed')} {(bw.error?.kind === 'install' || 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">
|
||||
{isBackend && !have && (
|
||||
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={() => setConfirm(entry)}>
|
||||
<Icon name={working ? 'spinner' : 'download'} spin={working} /> {working ? t('browse.installing') : t('browse.install')}
|
||||
</button>
|
||||
)}
|
||||
{!isBackend && !have && (
|
||||
{!have && (
|
||||
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working || pending} onClick={() => openInstall(entry)}>
|
||||
<Icon name={working ? 'spinner' : 'download'} spin={working} /> {t('flow.installAndTurnOn')}
|
||||
</button>
|
||||
@@ -157,23 +106,6 @@ export default function BrowseView({ api, adapter }) {
|
||||
emptyText={t('browse.emptyBody')}
|
||||
/>
|
||||
<InstallDialog entry={dialog} flow={dialog ? actions.flows[`flow:${dialog.key}`] : undefined} actions={actions} onClose={() => setDialog(null)} />
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirm)}
|
||||
title={confirm ? t('browse.confirmTitle', { name: confirm.title }) : ''}
|
||||
message={confirm ? (
|
||||
<div className="ad-confirm">
|
||||
<p>{t('browse.confirmBackendBody', { 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.confirmBackendNote')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
confirmLabel={t('browse.install')}
|
||||
onConfirm={() => { const e = confirm; setConfirm(null); install(e) }}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -181,7 +113,7 @@ export default function BrowseView({ api, adapter }) {
|
||||
// The details of an entry, folded into the entry itself: what it is made for,
|
||||
// how it is verified and which build it is. They come from the library entry
|
||||
// the list already has.
|
||||
function EntryFacts({ entry: e, have, isBackend }) {
|
||||
function EntryFacts({ entry: e, have }) {
|
||||
const { t } = useTranslation('addons')
|
||||
return (
|
||||
<details className="ad-entry__details" data-testid="entry-details">
|
||||
@@ -196,7 +128,7 @@ function EntryFacts({ entry: e, have, isBackend }) {
|
||||
<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 || e.policy.identityRegex}</dd></>)}
|
||||
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap">{e.digest}</dd>
|
||||
{!isBackend && have && (<><dt>{t('browse.installedState')}</dt><dd>{t('browse.installedDetail', { version: have.version, generation: have.generation })}</dd></>)}
|
||||
{have && (<><dt>{t('browse.installedState')}</dt><dd>{t('browse.installedDetail', { version: have.version, generation: have.generation })}</dd></>)}
|
||||
</dl>
|
||||
</details>
|
||||
)
|
||||
|
||||
+4
-2
@@ -269,9 +269,11 @@ export function createAddonsCatalogAdapter(api, { pageSize = 24 } = {}) {
|
||||
const payload = await api.dispatch({
|
||||
operationId: newOperationId(),
|
||||
action: 'catalog-search',
|
||||
args: { text: query?.text || '', kind: 'all', sourceId: query?.sourceId || '', offset, limit: pageSize },
|
||||
args: { text: query?.text || '', kind: 'addon', sourceId: query?.sourceId || '', offset, limit: pageSize },
|
||||
}, signal)
|
||||
const releases = Array.isArray(payload?.releases) ? payload.releases : []
|
||||
// Backends belong to the Backends page. The request asks for add-ons
|
||||
// only; an entry that still says it is a backend is dropped here too.
|
||||
const releases = (Array.isArray(payload?.releases) ? payload.releases : []).filter(r => !r.kind || r.kind === 'addon')
|
||||
return { entries: releases.map(toEntry), total: Number(payload?.total) || 0, offset }
|
||||
},
|
||||
async details(key, signal) {
|
||||
|
||||
@@ -143,6 +143,22 @@ test('update status comes only from an authoritative check', () => {
|
||||
assert.equal(parseEntryKey('a/b'), null)
|
||||
})
|
||||
|
||||
test('the add-on browse list drops backend entries and keeps entries with no kind', async () => {
|
||||
const rel = (id, kind) => ({ id, name: id, version: '1.0.0', sourceId: 'main', os: 'linux', arch: 'amd64', digest: 'sha256:a', ...(kind ? { kind } : {}) })
|
||||
let sent
|
||||
const api = createAddonsApi({
|
||||
base: p => p,
|
||||
fetch: async (url, init) => {
|
||||
sent = JSON.parse(init.body)
|
||||
return json(200, { payload: { releases: [rel('notes'), rel('tables', 'addon'), rel('cpu-local-store', 'backend')], total: 3 } })
|
||||
},
|
||||
})
|
||||
const page = await createAddonsCatalogAdapter(api).search({ text: '', sourceId: '', page: 1 })
|
||||
assert.equal(sent.arguments.kind, 'addon')
|
||||
assert.deepEqual(page.entries.map(e => e.id), ['notes', 'tables'])
|
||||
assert.ok(page.entries.every(e => e.kind === 'addon'))
|
||||
})
|
||||
|
||||
test('the api sends one dispatch shape and classifies refusals', async () => {
|
||||
const calls = []
|
||||
const api = createAddonsApi({
|
||||
@@ -162,7 +178,7 @@ 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', kind: 'all', sourceId: 'main', offset: 10, limit: 10 })
|
||||
assert.deepEqual(sent.arguments, { text: 'no', kind: 'addon', sourceId: 'main', offset: 10, limit: 10 })
|
||||
assert.equal(sent.action, 'catalog-search')
|
||||
assert.ok(sent.operationId)
|
||||
assert.equal(page.total, 31)
|
||||
@@ -289,7 +305,7 @@ test('a refusal shows the server reason and falls back to the generic text', ()
|
||||
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 () => {
|
||||
test('the add-on list drops a backend entry, and the backend route still installs one', async () => {
|
||||
const calls = []
|
||||
const api = createAddonsApi({
|
||||
fetch: async (url, init) => {
|
||||
@@ -303,9 +319,7 @@ test('a backend entry keeps its kind and installs through the backend route', as
|
||||
})
|
||||
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(page.entries, [])
|
||||
assert.deepEqual(await api.backends.installed(), [{ id: 'cpu-local-store', library: 'official-backends', version: '', digest: '' }, { id: 'llama-cpp', library: '', version: '', digest: '' }])
|
||||
await assert.rejects(
|
||||
api.backends.install({ sourceId: 'official-backends', name: 'cpu-local-store', version: 'v' }),
|
||||
|
||||
Reference in new issue
Block a user