diff --git a/core/http/react-ui/e2e/addons-backend-library.spec.js b/core/http/react-ui/e2e/addons-backend-library.spec.js index 1546f61c7..7a690365b 100644 --- a/core/http/react-ui/e2e/addons-backend-library.spec.js +++ b/core/http/react-ui/e2e/addons-backend-library.spec.js @@ -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 }) => { diff --git a/core/http/react-ui/src/components/addons/BrowseView.jsx b/core/http/react-ui/src/components/addons/BrowseView.jsx index 1545e77a4..5ae8ad25e 100644 --- a/core/http/react-ui/src/components/addons/BrowseView.jsx +++ b/core/http/react-ui/src/components/addons/BrowseView.jsx @@ -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 (
@@ -104,31 +67,17 @@ export default function BrowseView({ api, adapter }) {

{entry.description &&

{entry.description}

}
- {isBackend && {t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })}} - {!isBackend && addsPage && {t('card.addsPage')}} - {isBackend && have && {t('browse.installedBackend')}} - {!isBackend && have && {have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}} - {!isBackend && have && updateView(have.update).state === 'update-available' && {t('updates.badge.available', { version: updateView(have.update).version })}} + {addsPage && {t('card.addsPage')}} + {have && {have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}} + {have && updateView(have.update).state === 'update-available' && {t('updates.badge.available', { version: updateView(have.update).version })}}
- + {pending && } {!pending && !(dialog && dialog.key === entry.key) && actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' }) : undefined} />} {flow && flow.step === 'done' && !(dialog && dialog.key === entry.key) &&

{t('browse.installedOn', { name: entry.title })}

} - {bw && bw.phase !== 'done' && } - {bw?.phase === 'failed' && ( -

- {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)} -

- )} - {bw?.phase === 'done' &&

{t('browse.installedBackendNote')}

}
- {isBackend && !have && ( - - )} - {!isBackend && !have && ( + {!have && ( @@ -157,23 +106,6 @@ export default function BrowseView({ api, adapter }) { emptyText={t('browse.emptyBody')} /> setDialog(null)} /> - -

{t('browse.confirmBackendBody', { version: confirm.version, library: confirm.sourceId })}

-
-
{t('trust.digest')}
{shortDigest(confirm.digest)}
-
{t('trust.verification')}
{confirm.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
-
-

{t('browse.confirmBackendNote')}

-
- ) : 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 (
@@ -196,7 +128,7 @@ function EntryFacts({ entry: e, have, isBackend }) {
{t('trust.verification')}
{e.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
{e.policy.requireSignature && (<>
{t('trust.issuer')}
{e.policy.issuer}
{t('trust.identity')}
{e.policy.identity || e.policy.identityRegex}
)}
{t('trust.digest')}
{e.digest}
- {!isBackend && have && (<>
{t('browse.installedState')}
{t('browse.installedDetail', { version: have.version, generation: have.generation })}
)} + {have && (<>
{t('browse.installedState')}
{t('browse.installedDetail', { version: have.version, generation: have.generation })}
)}
) diff --git a/core/http/react-ui/src/utils/addonsApi.js b/core/http/react-ui/src/utils/addonsApi.js index 1d06984aa..364fb3816 100644 --- a/core/http/react-ui/src/utils/addonsApi.js +++ b/core/http/react-ui/src/utils/addonsApi.js @@ -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) { diff --git a/core/http/react-ui/tests/unit/addons.test.mjs b/core/http/react-ui/tests/unit/addons.test.mjs index 10dc708c4..1b6267fea 100644 --- a/core/http/react-ui/tests/unit/addons.test.mjs +++ b/core/http/react-ui/tests/unit/addons.test.mjs @@ -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' }),