Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 10 additions & 82 deletions core/http/react-ui/e2e/addons-backend-library.spec.js
Original file line number Diff line number Diff line change
@@ -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/.+)$' }
Expand Down Expand Up @@ -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 }) => {
Expand Down
100 changes: 16 additions & 84 deletions core/http/react-ui/src/components/addons/BrowseView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -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="" />
Expand All @@ -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>
Expand Down Expand Up @@ -157,31 +106,14 @@ 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)}
/>
</>
)
}

// 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">
Expand All @@ -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>
)
Expand Down
6 changes: 4 additions & 2 deletions core/http/react-ui/src/utils/addonsApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading