feat(cabana): plugin updates and marketplace search on the Plugins screen

- GET /plugins looks up each versioned plugin's releases through
  kiosk.Client and applies plugin:update's rule (NewestCompatible above
  the BaseVersion), with the update command and the framework a newer
  release needs
- GET /plugins/marketplace forwards q and category (validated like the
  registry), marks installed and incompatible plugins and returns the
  install command; ids outside the plugin ID shape are dropped
- 4 s per-call timeout, at most 4 lookups at once, in-process cache
  (searches 5 min, releases 15 min, failures 1 min); a failing registry
  gives status unavailable, an empty plugins.registry_url sends nothing
- SPA: update badges and Marketplace tab with copy buttons (clipboard
  fallback), hidden when the registry is disabled; no install button
- OpenAPI, SPA types, inventories, conformance case, en/pl strings, docs
This commit is contained in:
Jakub Zych
2026-10-29 19:20:14 +01:00
parent c4b1ac4076
commit 7e8a19399d
18 changed files with 1624 additions and 79 deletions

View File

@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import PluginsView from '../../src/views/PluginsView.vue'
import { settingsPath } from '../../src/state/useSettings'
import type { InstalledPlugin, PluginsOverview, SettingsEntry } from '../../src/api/types'
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
import type { InstalledPlugin, MarketplacePlugin, PluginsOverview, SettingsEntry } from '../../src/api/types'
import { API, mountApp, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
const PLUGINS = `${API}/plugins`
const MARKET = `${API}/plugins/marketplace`
function installed(overrides: Partial<InstalledPlugin> & { id: string }): InstalledPlugin {
return {
@@ -56,6 +57,7 @@ beforeEach(() => {
afterEach(() => {
document.body.innerHTML = ''
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
})
enableAutoUnmount(afterEach)
@@ -107,4 +109,163 @@ describe('PluginsView', () => {
}
expect(settingsPath(entry)).toBe('/system/plugins')
})
describe('with the registry', () => {
const registry = { enabled: true, status: 'ok' }
function withUpdate(): PluginsOverview {
return overview({
registry,
plugins: [
installed({
id: 'acme.blog',
name: 'Blog',
version: 'v0.1.0',
source: 'module',
listed: true,
latest: 'v0.1.1',
latestFramework: 'v0.7.0',
updateAvailable: true,
updateCommand: 'summer plugin:update acme.blog',
}),
installed({ id: 'acme.translate', version: 'v0.1.1-3-gabc1234', source: 'submodule', listed: true, latest: 'v0.1.1' }),
],
})
}
function marketPlugin(overrides: Partial<MarketplacePlugin> & { id: string }): MarketplacePlugin {
return {
name: '',
description: '',
category: 'content',
featured: false,
iconUrl: '',
latestVersion: 'v0.1.0',
latestFramework: 'v0.6.0',
installed: false,
compatible: true,
installCommand: `summer plugin:install ${overrides.id}`,
...overrides,
}
}
const marketBody = (data: MarketplacePlugin[], status = 'ok') => ({
body: { data, meta: { registry: { enabled: true, status }, total: data.length, categories: ['commerce', 'content'] } },
})
async function openMarketplace(wrapper: VueWrapper): Promise<void> {
await wrapper.find('[data-tab="marketplace"]').trigger('mousedown', { button: 0 })
await flushPromises()
}
it('shows the update badge with a copyable update command', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const { wrapper } = await openPlugins(withUpdate())
const blog = wrapper.find('[data-plugin="acme.blog"]')
expect(blog.find('[data-update-badge]').text()).toBe('Dostępna aktualizacja do v0.1.1')
expect(blog.find<HTMLInputElement>('[data-command="update:acme.blog"]').element.value).toBe('summer plugin:update acme.blog')
expect(blog.find('[data-plugin-needs-framework]').text()).toBe('Nowsze wydanie wymaga frameworka v0.7.0.')
expect(wrapper.find('[data-plugin="acme.translate"] [data-plugin-update]').exists()).toBe(false)
await blog.find('[data-action="copy-command"]').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('summer plugin:update acme.blog')
expect(blog.find('[data-action="copy-command"]').text()).toBe('Skopiowano')
expect(blog.find('[data-copy-failed]').exists()).toBe(false)
})
it('falls back to selecting the command without a clipboard', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
const { wrapper } = await openPlugins(withUpdate())
const blog = wrapper.find('[data-plugin="acme.blog"]')
await blog.find('[data-action="copy-command"]').trigger('click')
await flushPromises()
expect(blog.find('[data-copy-failed]').text()).toContain('Nie udało się skopiować')
expect(document.activeElement).toBe(blog.find('[data-command="update:acme.blog"]').element)
expect(blog.find('[data-action="copy-command"]').text()).toBe('Kopiuj')
})
it('hides the marketplace tab and the badges when the registry is disabled', async () => {
const disabled = withUpdate()
disabled.registry = { enabled: false, status: 'disabled' }
const { wrapper, calls } = await openPlugins(disabled)
expect(wrapper.find('[data-tab="installed"]').exists()).toBe(true)
expect(wrapper.find('[data-tab="marketplace"]').exists()).toBe(false)
expect(wrapper.find('[data-update-badge]').exists()).toBe(false)
expect(wrapper.find('[data-plugin-needs-framework]').exists()).toBe(false)
expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(0)
})
it('keeps the installed list and shows a notice when the registry is unavailable', async () => {
const down = overview({ registry: { enabled: true, status: 'unavailable' } })
const { wrapper } = await openPlugins(down)
expect(wrapper.find('[data-registry-unavailable]').exists()).toBe(true)
expect(wrapper.findAll('[data-plugin]')).toHaveLength(2)
})
it('searches the marketplace through cabana and offers install commands only', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const results = [
marketPlugin({ id: 'acme.blog', name: 'Blog', installed: true }),
marketPlugin({ id: 'acme.journal', name: 'Journal', description: 'A <b>journal</b>.', featured: true }),
marketPlugin({ id: 'acme.shop', name: 'Shop', compatible: false, latestFramework: 'v0.9.0', category: 'commerce' }),
]
const { wrapper, calls } = await openPlugins(withUpdate(), {
[`GET ${MARKET}`]: (request) => {
const q = queryOf(request).get('q') ?? ''
const category = queryOf(request).get('category') ?? ''
return marketBody(results.filter((p) => p.id.includes(q) && (category === '' || p.category === category)))
},
})
expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(0)
await openMarketplace(wrapper)
expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(1)
const cards = wrapper.findAll('[data-market-plugin]')
expect(cards.map((card) => card.attributes('data-market-plugin'))).toEqual(['acme.blog', 'acme.journal', 'acme.shop'])
expect(cards[0]!.find('[data-market-marker="installed"]').text()).toBe('Zainstalowana')
expect(cards[2]!.find('[data-market-marker="needs-framework"]').text()).toBe('Wymaga frameworka v0.9.0')
expect(cards[1]!.find('[data-market-description]').text()).toBe('A <b>journal</b>.')
expect(cards[1]!.find('b').exists()).toBe(false)
expect(cards[1]!.find<HTMLInputElement>('[data-command="install:acme.journal"]').element.value).toBe(
'summer plugin:install acme.journal',
)
// The only buttons on a card copy the command: nothing installs from here.
for (const card of cards) {
expect(card.findAll('button').map((button) => button.attributes('data-action'))).toEqual(['copy-command'])
}
const options = wrapper.findAll('[data-market-category] option').map((option) => option.attributes('value'))
expect(options).toEqual(['', 'commerce', 'content'])
await cards[1]!.find('[data-action="copy-command"]').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('summer plugin:install acme.journal')
await wrapper.find('[data-market-search]').setValue('journal')
expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(1)
await wait(350)
await flushPromises()
const searches = requestsTo(calls, 'GET', MARKET)
expect(searches).toHaveLength(2)
expect(queryOf(searches[1]!).get('q')).toBe('journal')
expect(wrapper.findAll('[data-market-plugin]')).toHaveLength(1)
await wrapper.find('[data-market-category]').setValue('commerce')
await flushPromises()
const filtered = requestsTo(calls, 'GET', MARKET)
expect(queryOf(filtered[2]!).get('category')).toBe('commerce')
expect(wrapper.find('[data-market-empty]').exists()).toBe(true)
})
it('shows the registry notice when the marketplace search fails', async () => {
const { wrapper } = await openPlugins(withUpdate(), {
[`GET ${MARKET}`]: marketBody([], 'unavailable'),
})
await openMarketplace(wrapper)
expect(wrapper.find('[data-market-unavailable]').exists()).toBe(true)
expect(wrapper.findAll('[data-market-plugin]')).toHaveLength(0)
})
})
})