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 & { id: string }): InstalledPlugin { return { name: '', description: '', version: '', source: '', module: '', package: '', listed: false, latest: '', latestFramework: '', updateAvailable: false, updateCommand: '', ...overrides, } } function overview(overrides: Partial = {}): PluginsOverview { return { framework: 'v0.6.0', registry: { enabled: false, status: 'disabled' }, plugins: [ installed({ id: 'acme.blog', name: 'Blog', description: 'Posts and 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 = {}) { 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 and 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 & { 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 { 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('[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 journal.', 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 journal.') expect(cards[1]!.find('b').exists()).toBe(false) expect(cards[1]!.find('[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) }) }) })