- PluginsView: tab switching, one request per typing burst and a blank search without q, name fallback on cards, copied state clearing, a refused clipboard write, a failed marketplace request - useSettings: settingsPath prefers path, then controller, then the singleton form; settingsEntryForPath
366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
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, 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 {
|
|
name: '',
|
|
description: '',
|
|
version: '',
|
|
source: '',
|
|
module: '',
|
|
package: '',
|
|
listed: false,
|
|
latest: '',
|
|
latestFramework: '',
|
|
updateAvailable: false,
|
|
updateCommand: '',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function overview(overrides: Partial<PluginsOverview> = {}): PluginsOverview {
|
|
return {
|
|
framework: 'v0.6.0',
|
|
registry: { enabled: false, status: 'disabled' },
|
|
plugins: [
|
|
installed({
|
|
id: 'acme.blog',
|
|
name: 'Blog',
|
|
description: 'Posts <b>and</b> categories.',
|
|
version: 'v0.1.0',
|
|
source: 'module',
|
|
module: 'example.test/acme-blog',
|
|
package: 'example.test/acme-blog',
|
|
}),
|
|
installed({ id: 'acme.local', source: 'local' }),
|
|
],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const envelope = (data: unknown) => ({ body: { data, meta: {} } })
|
|
|
|
async function openPlugins(data: PluginsOverview = overview(), routes: Record<string, Route> = {}) {
|
|
return mountApp('/system/plugins', { [`GET ${PLUGINS}`]: envelope(data), ...routes }, { attach: true })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
|
|
})
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
describe('PluginsView', () => {
|
|
it('renders the installed plugins from GET /plugins', async () => {
|
|
const { wrapper, calls } = await openPlugins()
|
|
expect(wrapper.findComponent(PluginsView).exists()).toBe(true)
|
|
expect(requestsTo(calls, 'GET', PLUGINS)).toHaveLength(1)
|
|
expect(wrapper.find('h1').text()).toBe('Wtyczki')
|
|
expect(wrapper.find('[data-plugins-framework]').text()).toBe('Framework v0.6.0')
|
|
|
|
const rows = wrapper.findAll('[data-plugin]')
|
|
expect(rows.map((row) => row.attributes('data-plugin'))).toEqual(['acme.blog', 'acme.local'])
|
|
const blog = rows[0]!
|
|
expect(blog.find('[data-plugin-name]').text()).toBe('Blog')
|
|
expect(blog.find('[data-plugin-version]').text()).toBe('v0.1.0')
|
|
expect(blog.find('[data-plugin-source]').text()).toBe('Moduł Go')
|
|
// Registry and plugin text is text, never HTML.
|
|
expect(blog.find('[data-plugin-description]').text()).toBe('Posts <b>and</b> categories.')
|
|
expect(blog.find('b').exists()).toBe(false)
|
|
|
|
const local = rows[1]!
|
|
expect(local.find('[data-plugin-name]').text()).toBe('acme.local')
|
|
expect(local.find('[data-plugin-version]').text()).toBe('Brak wersji')
|
|
expect(local.find('[data-plugin-source]').text()).toBe('W aplikacji')
|
|
})
|
|
|
|
it('shows an error when the list cannot be loaded', async () => {
|
|
const { wrapper } = await openPlugins(overview(), {
|
|
[`GET ${PLUGINS}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden', details: {} } } },
|
|
})
|
|
expect(wrapper.find('[data-plugins-failed]').exists()).toBe(true)
|
|
expect(wrapper.findAll('[data-plugin]')).toHaveLength(0)
|
|
})
|
|
|
|
it('opens from the settings entry path', () => {
|
|
const entry: SettingsEntry = {
|
|
code: 'plugins',
|
|
label: 'Wtyczki',
|
|
description: '',
|
|
category: 'System',
|
|
icon: 'puzzle',
|
|
order: 120,
|
|
keywords: [],
|
|
model: '',
|
|
controller: '',
|
|
path: '/system/plugins',
|
|
}
|
|
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('switches between the Installed and Marketplace tabs', async () => {
|
|
const { wrapper } = await openPlugins(withUpdate(), {
|
|
[`GET ${MARKET}`]: marketBody([marketPlugin({ id: 'acme.journal', name: 'Journal' })]),
|
|
})
|
|
expect(wrapper.find('[data-tab="installed"]').attributes('data-state')).toBe('active')
|
|
expect(wrapper.find('[data-plugin="acme.blog"]').isVisible()).toBe(true)
|
|
await openMarketplace(wrapper)
|
|
expect(wrapper.find('[data-tab="marketplace"]').attributes('data-state')).toBe('active')
|
|
expect(wrapper.find('[data-market-plugin="acme.journal"]').exists()).toBe(true)
|
|
expect(wrapper.find('[data-plugin="acme.blog"]').exists()).toBe(false)
|
|
await wrapper.find('[data-tab="installed"]').trigger('mousedown', { button: 0 })
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-plugin="acme.blog"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('sends one search request for a burst of typing', async () => {
|
|
const { wrapper, calls } = await openPlugins(withUpdate(), {
|
|
[`GET ${MARKET}`]: (request) => marketBody([marketPlugin({ id: `acme.${queryOf(request).get('q') ?? 'all'}` })]),
|
|
})
|
|
await openMarketplace(wrapper)
|
|
expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(1)
|
|
const search = wrapper.find('[data-market-search]')
|
|
for (const value of ['b', 'bl', 'blo', 'blog']) {
|
|
await search.setValue(value)
|
|
await wait(50)
|
|
}
|
|
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('blog')
|
|
// A blank search asks for everything, without a q parameter.
|
|
await search.setValue(' ')
|
|
await wait(350)
|
|
await flushPromises()
|
|
const blank = requestsTo(calls, 'GET', MARKET)
|
|
expect(blank).toHaveLength(3)
|
|
expect(queryOf(blank[2]!).has('q')).toBe(false)
|
|
})
|
|
|
|
it('falls back to the ID when a plugin has no name', async () => {
|
|
const { wrapper } = await openPlugins(withUpdate(), {
|
|
[`GET ${MARKET}`]: marketBody([marketPlugin({ id: 'acme.nameless', name: '', latestVersion: '' })]),
|
|
})
|
|
expect(wrapper.find('[data-plugin="acme.translate"] [data-plugin-name]').text()).toBe('acme.translate')
|
|
await openMarketplace(wrapper)
|
|
const card = wrapper.find('[data-market-plugin="acme.nameless"]')
|
|
expect(card.find('[data-market-name]').text()).toBe('acme.nameless')
|
|
expect(card.find('[data-market-version]').exists()).toBe(false)
|
|
})
|
|
|
|
it('copies an install command and clears the copied state after two seconds', async () => {
|
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
|
const { wrapper } = await openPlugins(withUpdate(), {
|
|
[`GET ${MARKET}`]: marketBody([marketPlugin({ id: 'acme.journal' }), marketPlugin({ id: 'acme.shop' })]),
|
|
})
|
|
await openMarketplace(wrapper)
|
|
const journal = () => wrapper.find('[data-market-plugin="acme.journal"] [data-action="copy-command"]')
|
|
const shop = () => wrapper.find('[data-market-plugin="acme.shop"] [data-action="copy-command"]')
|
|
await journal().trigger('click')
|
|
await flushPromises()
|
|
expect(writeText).toHaveBeenCalledExactlyOnceWith('summer plugin:install acme.journal')
|
|
expect(journal().text()).toBe('Skopiowano')
|
|
expect(shop().text()).toBe('Kopiuj')
|
|
await wait(2100)
|
|
await flushPromises()
|
|
expect(journal().text()).toBe('Kopiuj')
|
|
})
|
|
|
|
it('falls back when the clipboard refuses the write', async () => {
|
|
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
|
const { wrapper } = await openPlugins(withUpdate(), {
|
|
[`GET ${MARKET}`]: marketBody([marketPlugin({ id: 'acme.journal' })]),
|
|
})
|
|
await openMarketplace(wrapper)
|
|
const card = wrapper.find('[data-market-plugin="acme.journal"]')
|
|
await card.find('[data-action="copy-command"]').trigger('click')
|
|
await flushPromises()
|
|
expect(card.find('[data-copy-failed]').exists()).toBe(true)
|
|
expect(document.activeElement).toBe(card.find('[data-command="install:acme.journal"]').element)
|
|
})
|
|
|
|
it('shows the registry notice when the marketplace request itself fails', async () => {
|
|
const { wrapper } = await openPlugins(withUpdate(), {
|
|
[`GET ${MARKET}`]: { status: 500, body: { error: { code: 'internal', message: 'x', details: {} } } },
|
|
})
|
|
await openMarketplace(wrapper)
|
|
expect(wrapper.find('[data-market-unavailable]').exists()).toBe(true)
|
|
expect(wrapper.find('[data-market-empty]').exists()).toBe(false)
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
})
|