Plugins Catalog: refactor the mocking for our tests (#39165)

* refactor(Plugins/Admin): add a type for version coming back from the API

* refactor(Plugins/Admin): only add necessary version information to the state

* test(Plugins/Admin): add a mock for a CatalogPlugin

* test(Plugins/ADmin): add a mock for a LocalPlugin

* test(Plugins/Admin): add a test for a RemotePlugin

* test(Plugins/Admin): add helpers for mocking plugin states

* refactor(Plugins/Admin): mock the Redux state instead of the API responses

This makes it simpler to add new test cases and also makes
the tests easier to reason about.

* refactor(Plugins/Admin): mock the Redux state instead of the API responses
This commit is contained in:
Levente Balogh 2021-09-14 16:44:13 +02:00 committed by GitHub
parent bff3d91b0d
commit 3c433dc36d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 696 additions and 482 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
export { default as remotePluginMock } from './remotePlugin.mock';
export { default as localPluginMock } from './localPlugin.mock';
export * from './mockHelpers';

View File

@ -0,0 +1,72 @@
import { LocalPlugin } from '../types';
// Copied from /api/plugins
export default {
name: 'Zabbix',
type: 'app',
id: 'alexanderzobnin-zabbix-app',
enabled: false,
pinned: false,
info: {
author: {
name: 'Alexander Zobnin',
url: 'https://github.com/alexanderzobnin',
},
description: 'Zabbix plugin for Grafana',
links: [
{
name: 'GitHub',
url: 'https://github.com/alexanderzobnin/grafana-zabbix',
},
{
name: 'Docs',
url: 'https://alexanderzobnin.github.io/grafana-zabbix',
},
{
name: 'License',
url: 'https://github.com/alexanderzobnin/grafana-zabbix/blob/master/LICENSE',
},
],
logos: {
small: 'public/plugins/alexanderzobnin-zabbix-app/img/icn-zabbix-app.svg',
large: 'public/plugins/alexanderzobnin-zabbix-app/img/icn-zabbix-app.svg',
},
build: {
time: 1629903250076,
repo: 'git@github.com:alexanderzobnin/grafana-zabbix.git',
hash: 'e9db978235cd6d01a095a37f3aa711ea8ea0f7ab',
},
screenshots: [
{
path: 'public/plugins/alexanderzobnin-zabbix-app/img/screenshot-showcase.png',
name: 'Showcase',
},
{
path: 'public/plugins/alexanderzobnin-zabbix-app/img/screenshot-dashboard01.png',
name: 'Dashboard',
},
{
path: 'public/plugins/alexanderzobnin-zabbix-app/img/screenshot-annotations.png',
name: 'Annotations',
},
{
path: 'public/plugins/alexanderzobnin-zabbix-app/img/screenshot-metric_editor.png',
name: 'Metric Editor',
},
{
path: 'public/plugins/alexanderzobnin-zabbix-app/img/screenshot-triggers.png',
name: 'Triggers',
},
],
version: '4.2.2',
updated: '2021-08-25',
},
latestVersion: '',
hasUpdate: false,
defaultNavUrl: '/plugins/alexanderzobnin-zabbix-app/',
category: '',
state: '',
signature: 'valid',
signatureType: 'community',
signatureOrg: 'Alexander Zobnin',
} as LocalPlugin;

View File

@ -0,0 +1,82 @@
import { setBackendSrv } from '@grafana/runtime';
import { PluginsState } from 'app/types';
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
import { CatalogPlugin, LocalPlugin, RemotePlugin, Version } from '../types';
import remotePluginMock from './remotePlugin.mock';
import localPluginMock from './localPlugin.mock';
import catalogPluginMock from './catalogPlugin.mock';
// Returns a sample mock for a CatalogPlugin plugin with the possibility to extend it
export const getCatalogPluginMock = (overrides?: Partial<CatalogPlugin>) => ({ ...catalogPluginMock, ...overrides });
// Returns a sample mock for a local (installed) plugin with the possibility to extend it
export const getLocalPluginMock = (overrides?: Partial<LocalPlugin>) => ({ ...localPluginMock, ...overrides });
// Returns a sample mock for a remote plugin with the possibility to extend it
export const getRemotePluginMock = (overrides?: Partial<RemotePlugin>) => ({ ...remotePluginMock, ...overrides });
// Returns a mock for the Redux store state of plugins
export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): PluginsState => ({
// @ts-ignore - We don't need the rest of the properties here as we are using the "new" reducer (public/app/features/plugins/admin/state/reducer.ts)
items: {
ids: plugins.map(({ id }) => id),
entities: plugins.reduce((prev, current) => ({ ...prev, [current.id]: current }), {}),
},
requests: {
'plugins/fetchAll': {
status: 'Fulfilled',
},
'plugins/fetchDetails': {
status: 'Fulfilled',
},
},
});
// Mocks a plugin by considering what needs to be mocked from GCOM and what needs to be mocked locally (local Grafana API)
export const mockPluginApis = ({
remote: remoteOverride,
local: localOverride,
versions,
}: {
remote?: Partial<RemotePlugin>;
local?: Partial<LocalPlugin>;
versions?: Version[];
}) => {
const remote = getRemotePluginMock(remoteOverride);
const local = getLocalPluginMock(localOverride);
const original = jest.requireActual('@grafana/runtime');
const originalBackendSrv = original.getBackendSrv();
setBackendSrv({
...originalBackendSrv,
get: (path: string) => {
// Mock GCOM plugins (remote) if necessary
if (remote && path === `${GRAFANA_API_ROOT}/plugins`) {
return Promise.resolve({ items: [remote] });
}
// Mock GCOM single plugin page (remote) if necessary
if (remote && path === `${GRAFANA_API_ROOT}/plugins/${remote.slug}`) {
return Promise.resolve(remote);
}
// Mock versions
if (versions && path === `${GRAFANA_API_ROOT}/plugins/${remote.slug}/versions`) {
return Promise.resolve({ items: versions });
}
// Mock local plugin settings (installed) if necessary
if (local && path === `${API_ROOT}/${local.id}/settings`) {
return Promise.resolve(local);
}
// Mock local plugin listing (of necessary)
if (local && path === API_ROOT) {
return Promise.resolve([local]);
}
// Fall back to the original .get() in other cases
return originalBackendSrv.get(path);
},
});
};

View File

@ -0,0 +1,48 @@
import { PluginSignatureType, PluginType } from '@grafana/data';
import { RemotePlugin } from '../types';
// Copied from /api/gnet/plugins/alexanderzobnin-zabbix-app
export default {
createdAt: '2016-04-06T20:23:41.000Z',
description: 'Zabbix plugin for Grafana',
downloads: 33645089,
featured: 180,
id: 74,
typeId: 1,
typeName: 'Application',
internal: false,
links: [],
name: 'Zabbix',
orgId: 13056,
orgName: 'Alexander Zobnin',
orgSlug: 'alexanderzobnin',
orgUrl: 'https://github.com/alexanderzobnin',
url: 'https://github.com/alexanderzobnin/grafana-zabbix',
verified: false,
downloadSlug: 'alexanderzobnin-zabbix-app',
packages: {},
popularity: 0.2111,
signatureType: PluginSignatureType.community,
slug: 'alexanderzobnin-zabbix-app',
status: 'active',
typeCode: PluginType.app,
updatedAt: '2021-05-18T14:53:01.000Z',
version: '4.1.5',
versionStatus: 'active',
versionSignatureType: PluginSignatureType.community,
versionSignedByOrg: 'alexanderzobnin',
versionSignedByOrgName: 'Alexander Zobnin',
userId: 0,
readme:
'<h1>Zabbix plugin for Grafana</h1>\n<p>:copyright: 2015-2021 Alexander Zobnin alexanderzobnin@gmail.com</p>\n<p>Licensed under the Apache 2.0 License</p>',
json: {
dependencies: {
grafanaDependency: '>=7.3.0',
grafanaVersion: '7.3',
plugins: [],
},
info: {
links: [],
},
},
} as RemotePlugin;

View File

@ -1,7 +1,16 @@
import { getBackendSrv } from '@grafana/runtime';
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
import { PluginDetails, Org, LocalPlugin, RemotePlugin, CatalogPlugin, CatalogPluginDetails } from './types';
import { mergeLocalsAndRemotes, mergeLocalAndRemote } from './helpers';
import {
PluginDetails,
Org,
LocalPlugin,
RemotePlugin,
CatalogPlugin,
CatalogPluginDetails,
Version,
PluginVersion,
} from './types';
export async function getCatalogPlugins(): Promise<CatalogPlugin[]> {
const [localPlugins, remotePlugins] = await Promise.all([getLocalPlugins(), getRemotePlugins()]);
@ -69,10 +78,13 @@ async function getRemotePlugin(id: string, isInstalled: boolean): Promise<Remote
}
}
async function getPluginVersions(id: string): Promise<any[]> {
async function getPluginVersions(id: string): Promise<Version[]> {
try {
const versions = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}/versions`);
return versions.items;
const versions: { items: PluginVersion[] } = await getBackendSrv().get(
`${GRAFANA_API_ROOT}/plugins/${id}/versions`
);
return (versions.items || []).map(({ version, createdAt }) => ({ version, createdAt }));
} catch (error) {
return [];
}

View File

@ -3,38 +3,22 @@ import { Router } from 'react-router-dom';
import { render, RenderResult, waitFor, within } from '@testing-library/react';
import { Provider } from 'react-redux';
import { locationService } from '@grafana/runtime';
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
import BrowsePage from './Browse';
import { PluginType } from '@grafana/data';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { configureStore } from 'app/store/configureStore';
import { LocalPlugin, RemotePlugin, PluginAdminRoutes } from '../types';
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
import { PluginAdminRoutes, CatalogPlugin } from '../types';
import { getCatalogPluginMock, getPluginsStateMock } from '../__mocks__';
import BrowsePage from './Browse';
// Mock the config to enable the plugin catalog
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');
return {
...original,
getBackendSrv: () => ({
get: (path: string) => {
switch (path) {
case `${GRAFANA_API_ROOT}/plugins`:
return Promise.resolve({ items: remote });
case API_ROOT:
return Promise.resolve(installed);
default:
return Promise.reject();
}
},
}),
config: {
...original.config,
pluginAdminEnabled: true,
},
};
return { ...original, pluginAdminEnabled: true };
});
function setup(path = '/plugins'): RenderResult {
const store = configureStore();
const renderBrowse = (path = '/plugins', plugins: CatalogPlugin[] = []): RenderResult => {
const store = configureStore({ plugins: getPluginsStateMock(plugins) });
locationService.push(path);
const props = getRouteComponentProps({
route: { routeName: PluginAdminRoutes.Home } as any,
@ -47,108 +31,142 @@ function setup(path = '/plugins'): RenderResult {
</Router>
</Provider>
);
}
};
describe('Browse list of plugins', () => {
describe('when filtering', () => {
it('should list installed plugins by default', async () => {
const { queryByText } = setup('/plugins');
const { queryByText } = renderBrowse('/plugins', [
getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 4', isInstalled: false }),
]);
await waitFor(() => queryByText('Installed'));
await waitFor(() => expect(queryByText('Plugin 1')).toBeInTheDocument());
expect(queryByText('Plugin 1')).toBeInTheDocument();
expect(queryByText('Plugin 2')).toBeInTheDocument();
expect(queryByText('Plugin 3')).toBeInTheDocument();
for (const plugin of installed) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
for (const plugin of remote) {
expect(queryByText(plugin.name)).toBeNull();
}
expect(queryByText('Plugin 4')).toBeNull();
});
it('should list all plugins (except core plugins) when filtering by all', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=all');
const { queryByText } = renderBrowse('/plugins?filterBy=all&filterByType=all', [
getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: false }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 4', isInstalled: true, isCore: true }),
]);
await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument());
for (const plugin of remote) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
await waitFor(() => expect(queryByText('Plugin 1')).toBeInTheDocument());
expect(queryByText('Plugin 2')).toBeInTheDocument();
expect(queryByText('Plugin 3')).toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
// Core plugins should not be listed
expect(queryByText('Plugin 4')).not.toBeInTheDocument();
});
it('should list installed plugins (including core plugins) when filtering by installed', async () => {
const { queryByText } = setup('/plugins?filterBy=installed');
const { queryByText } = renderBrowse('/plugins?filterBy=installed', [
getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: false }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', isInstalled: true }),
getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 4', isInstalled: true, isCore: true }),
]);
await waitFor(() => queryByText('Installed'));
await waitFor(() => expect(queryByText('Plugin 1')).toBeInTheDocument());
expect(queryByText('Plugin 3')).toBeInTheDocument();
expect(queryByText('Plugin 4')).toBeInTheDocument();
for (const plugin of installed) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
for (const plugin of remote) {
expect(queryByText(plugin.name)).not.toBeInTheDocument();
}
// Not showing not installed plugins
expect(queryByText('Plugin 2')).not.toBeInTheDocument();
});
it('should list enterprise plugins', async () => {
const { queryByText } = setup('/plugins?filterBy=all&q=wavefront');
it('should list enterprise plugins when querying for them', async () => {
const { queryByText } = renderBrowse('/plugins?filterBy=all&q=wavefront', [
getCatalogPluginMock({ id: 'wavefront', name: 'Wavefront', isInstalled: true, isEnterprise: true }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: true, isCore: true }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', isInstalled: true }),
]);
await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument());
// Should not show plugins that don't match the query
expect(queryByText('Plugin 2')).not.toBeInTheDocument();
expect(queryByText('Plugin 3')).not.toBeInTheDocument();
});
it('should list only datasource plugins when filtering by datasource', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=datasource');
const { queryByText } = renderBrowse('/plugins?filterBy=all&filterByType=datasource', [
getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', type: PluginType.app }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', type: PluginType.datasource }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', type: PluginType.panel }),
]);
await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument());
await waitFor(() => expect(queryByText('Plugin 2')).toBeInTheDocument());
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
expect(queryByText('Zabbix')).not.toBeInTheDocument();
expect(queryByText('ACE.SVG')).not.toBeInTheDocument();
// Other plugin types shouldn't be shown
expect(queryByText('Plugin 1')).not.toBeInTheDocument();
expect(queryByText('Plugin 3')).not.toBeInTheDocument();
});
it('should list only panel plugins when filtering by panel', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=panel');
const { queryByText } = renderBrowse('/plugins?filterBy=all&filterByType=panel', [
getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', type: PluginType.app }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', type: PluginType.datasource }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', type: PluginType.panel }),
]);
await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument());
expect(queryByText('ACE.SVG')).toBeInTheDocument();
await waitFor(() => expect(queryByText('Plugin 3')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Zabbix')).not.toBeInTheDocument();
// Other plugin types shouldn't be shown
expect(queryByText('Plugin 1')).not.toBeInTheDocument();
expect(queryByText('Plugin 2')).not.toBeInTheDocument();
});
it('should list only app plugins when filtering by app', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=app');
const { queryByText } = renderBrowse('/plugins?filterBy=all&filterByType=app', [
getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', type: PluginType.app }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', type: PluginType.datasource }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', type: PluginType.panel }),
]);
await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument());
await waitFor(() => expect(queryByText('Plugin 1')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
expect(queryByText('ACE.SVG')).not.toBeInTheDocument();
// Other plugin types shouldn't be shown
expect(queryByText('Plugin 2')).not.toBeInTheDocument();
expect(queryByText('Plugin 3')).not.toBeInTheDocument();
});
});
describe('when searching', () => {
it('should only list plugins matching search', async () => {
const { queryByText } = setup('/plugins?filterBy=all&q=zabbix');
const { queryByText } = renderBrowse('/plugins?filterBy=all&q=zabbix', [
getCatalogPluginMock({ id: 'zabbix', name: 'Zabbix' }),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2' }),
getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3' }),
]);
await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
expect(queryByText('Redis Application')).not.toBeInTheDocument();
// Other plugin types shouldn't be shown
expect(queryByText('Plugin 2')).not.toBeInTheDocument();
expect(queryByText('Plugin 3')).not.toBeInTheDocument();
});
});
describe('when sorting', () => {
it('should sort plugins by name in ascending alphabetical order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all');
const { findByTestId } = renderBrowse('/plugins?filterBy=all', [
getCatalogPluginMock({ id: 'wavefront', name: 'Wavefront' }),
getCatalogPluginMock({ id: 'redis-application', name: 'Redis Application' }),
getCatalogPluginMock({ id: 'zabbix', name: 'Zabbix' }),
getCatalogPluginMock({ id: 'diagram', name: 'Diagram' }),
getCatalogPluginMock({ id: 'acesvg', name: 'ACE.SVG' }),
]);
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'ACE.SVG',
'Diagram',
@ -159,11 +177,16 @@ describe('Browse list of plugins', () => {
});
it('should sort plugins by name in descending alphabetical order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all&sortBy=nameDesc');
const { findByTestId } = renderBrowse('/plugins?filterBy=all&sortBy=nameDesc', [
getCatalogPluginMock({ id: 'wavefront', name: 'Wavefront' }),
getCatalogPluginMock({ id: 'redis-application', name: 'Redis Application' }),
getCatalogPluginMock({ id: 'zabbix', name: 'Zabbix' }),
getCatalogPluginMock({ id: 'diagram', name: 'Diagram' }),
getCatalogPluginMock({ id: 'acesvg', name: 'ACE.SVG' }),
]);
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'Zabbix',
'Wavefront',
@ -174,11 +197,16 @@ describe('Browse list of plugins', () => {
});
it('should sort plugins by date in ascending updated order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all&sortBy=updated');
const { findByTestId } = renderBrowse('/plugins?filterBy=all&sortBy=updated', [
getCatalogPluginMock({ id: '1', name: 'Wavefront', updatedAt: '2021-04-01T00:00:00.000Z' }),
getCatalogPluginMock({ id: '2', name: 'Redis Application', updatedAt: '2021-02-01T00:00:00.000Z' }),
getCatalogPluginMock({ id: '3', name: 'Zabbix', updatedAt: '2021-01-01T00:00:00.000Z' }),
getCatalogPluginMock({ id: '4', name: 'Diagram', updatedAt: '2021-05-01T00:00:00.000Z' }),
getCatalogPluginMock({ id: '5', name: 'ACE.SVG', updatedAt: '2021-02-01T00:00:00.000Z' }),
]);
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'Diagram',
'Wavefront',
@ -189,26 +217,36 @@ describe('Browse list of plugins', () => {
});
it('should sort plugins by date in ascending published order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all&sortBy=published');
const { findByTestId } = renderBrowse('/plugins?filterBy=all&sortBy=published', [
getCatalogPluginMock({ id: '1', name: 'Wavefront', publishedAt: '2021-04-01T00:00:00.000Z' }),
getCatalogPluginMock({ id: '2', name: 'Redis Application', publishedAt: '2021-02-01T00:00:00.000Z' }),
getCatalogPluginMock({ id: '3', name: 'Zabbix', publishedAt: '2021-01-01T00:00:00.000Z' }),
getCatalogPluginMock({ id: '4', name: 'Diagram', publishedAt: '2021-05-01T00:00:00.000Z' }),
getCatalogPluginMock({ id: '5', name: 'ACE.SVG', publishedAt: '2021-02-01T00:00:00.000Z' }),
]);
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'Diagram',
'Wavefront',
'Redis Application',
'ACE.SVG',
'Wavefront',
'Zabbix',
]);
});
it('should sort plugins by number of downloads in ascending order', async () => {
const { findByTestId } = setup('/plugins?filterBy=all&sortBy=downloads');
const { findByTestId } = renderBrowse('/plugins?filterBy=all&sortBy=downloads', [
getCatalogPluginMock({ id: '1', name: 'Wavefront', downloads: 30 }),
getCatalogPluginMock({ id: '2', name: 'Redis Application', downloads: 10 }),
getCatalogPluginMock({ id: '3', name: 'Zabbix', downloads: 50 }),
getCatalogPluginMock({ id: '4', name: 'Diagram', downloads: 20 }),
getCatalogPluginMock({ id: '5', name: 'ACE.SVG', downloads: 40 }),
]);
const pluginList = await findByTestId('plugin-list');
const pluginHeadings = within(pluginList).queryAllByRole('heading');
expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([
'Zabbix',
'ACE.SVG',
@ -219,215 +257,3 @@ describe('Browse list of plugins', () => {
});
});
});
const installed: LocalPlugin[] = [
{
name: 'Alert Manager',
type: PluginType.datasource,
id: 'alertmanager',
enabled: true,
pinned: false,
info: {
author: {
name: 'Prometheus alertmanager',
url: 'https://grafana.com',
},
description: '',
links: [
{
name: 'Learn more',
url: 'https://prometheus.io/docs/alerting/latest/alertmanager/',
},
],
logos: {
small: 'public/app/plugins/datasource/alertmanager/img/logo.svg',
large: 'public/app/plugins/datasource/alertmanager/img/logo.svg',
},
build: {},
screenshots: null,
version: '',
updated: '',
},
latestVersion: '',
hasUpdate: false,
defaultNavUrl: '/plugins/alertmanager/',
category: '',
state: 'alpha',
signature: PluginSignatureStatus.internal,
signatureType: PluginSignatureType.core,
signatureOrg: '',
},
{
name: 'Diagram',
type: PluginType.panel,
id: 'jdbranham-diagram-panel',
enabled: true,
pinned: false,
info: {
author: { name: 'Jeremy Branham', url: 'https://savantly.net' },
description: 'Display diagrams and charts with colored metric indicators',
links: [
{
name: 'Project site',
url: 'https://github.com/jdbranham/grafana-diagram',
},
{
name: 'Apache License',
url: 'https://github.com/jdbranham/grafana-diagram/blob/master/LICENSE',
},
],
logos: {
small: 'public/plugins/jdbranham-diagram-panel/img/logo.svg',
large: 'public/plugins/jdbranham-diagram-panel/img/logo.svg',
},
build: {},
screenshots: [],
version: '1.7.3',
updated: '2021-07-20',
},
latestVersion: '1.7.3',
hasUpdate: true,
defaultNavUrl: '/plugins/jdbranham-diagram-panel/',
category: '',
state: '',
signature: PluginSignatureStatus.missing,
signatureType: PluginSignatureType.core,
signatureOrg: '',
},
{
name: 'Redis Application',
type: PluginType.app,
id: 'redis-app',
enabled: false,
pinned: false,
info: {
author: {
name: 'RedisGrafana',
url: 'https://redisgrafana.github.io',
},
description: 'Provides Application pages and custom panels for Redis Data Source.',
links: [
{ name: 'Website', url: 'https://redisgrafana.github.io' },
{
name: 'License',
url: 'https://github.com/RedisGrafana/grafana-redis-app/blob/master/LICENSE',
},
],
logos: {
small: 'public/plugins/redis-app/img/logo.svg',
large: 'public/plugins/redis-app/img/logo.svg',
},
build: {},
screenshots: [],
version: '2.0.1',
updated: '2021-07-07',
},
latestVersion: '2.0.1',
hasUpdate: false,
defaultNavUrl: '/plugins/redis-app/',
category: '',
state: '',
signature: PluginSignatureStatus.valid,
signatureType: PluginSignatureType.commercial,
signatureOrg: 'RedisGrafana',
},
];
const remote: RemotePlugin[] = [
{
status: 'active',
id: 74,
typeId: 1,
typeName: 'Application',
typeCode: PluginType.app,
slug: 'alexanderzobnin-zabbix-app',
name: 'Zabbix',
description: 'Zabbix plugin for Grafana',
version: '4.1.5',
versionStatus: 'active',
versionSignatureType: PluginSignatureType.community,
versionSignedByOrg: 'alexanderzobnin',
versionSignedByOrgName: 'Alexander Zobnin',
userId: 0,
orgId: 13056,
orgName: 'Alexander Zobnin',
orgSlug: 'alexanderzobnin',
orgUrl: 'https://github.com/alexanderzobnin',
url: 'https://github.com/alexanderzobnin/grafana-zabbix',
createdAt: '2016-04-06T20:23:41.000Z',
updatedAt: '2021-05-18T14:53:01.000Z',
downloads: 34387994,
verified: false,
featured: 180,
internal: false,
downloadSlug: 'alexanderzobnin-zabbix-app',
popularity: 0.2019,
signatureType: PluginSignatureType.community,
packages: {},
links: [],
},
{
status: 'enterprise',
id: 658,
typeId: 2,
typeName: 'Data Source',
typeCode: PluginType.datasource,
slug: 'grafana-wavefront-datasource',
name: 'Wavefront',
description: 'Wavefront Datasource',
version: '1.0.8',
versionStatus: 'active',
versionSignatureType: PluginSignatureType.grafana,
versionSignedByOrg: 'grafana',
versionSignedByOrgName: 'Grafana Labs',
userId: 0,
orgId: 5000,
orgName: 'Grafana Labs',
orgSlug: 'grafana',
orgUrl: 'https://grafana.org',
url: 'https://github.com/grafana/wavefront-datasource/',
createdAt: '2020-09-01T13:02:57.000Z',
updatedAt: '2021-07-12T18:41:03.000Z',
downloads: 7818,
verified: false,
featured: 0,
internal: false,
downloadSlug: 'grafana-wavefront-datasource',
popularity: 0.0107,
signatureType: PluginSignatureType.grafana,
packages: {},
links: [],
},
{
status: 'active',
id: 659,
typeId: 3,
typeName: 'Panel',
typeCode: PluginType.panel,
slug: 'aceiot-svg-panel',
name: 'ACE.SVG',
description: 'SVG Visualization Panel',
version: '0.0.10',
versionStatus: 'active',
versionSignatureType: PluginSignatureType.community,
versionSignedByOrg: 'aceiot',
versionSignedByOrgName: 'Andrew Rodgers',
userId: 0,
orgId: 409764,
orgName: 'Andrew Rodgers',
orgSlug: 'aceiot',
orgUrl: '',
url: 'https://github.com/ACE-IoT-Solutions/ace-svg-react',
createdAt: '2020-09-01T14:46:44.000Z',
updatedAt: '2021-06-28T14:01:36.000Z',
downloads: 101569,
verified: false,
featured: 0,
internal: false,
downloadSlug: 'aceiot-svg-panel',
popularity: 0.0134,
signatureType: PluginSignatureType.community,
packages: {},
links: [],
},
];

View File

@ -3,93 +3,41 @@ import { Provider } from 'react-redux';
import { render, RenderResult, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { config } from '@grafana/runtime';
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
import { configureStore } from 'app/store/configureStore';
import PluginDetailsPage from './PluginDetails';
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
import { LocalPlugin, RemotePlugin } from '../types';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { CatalogPlugin } from '../types';
import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock } from '../__mocks__';
// Mock the config to enable the plugin catalog
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');
const mockedRuntime = { ...original };
return {
...original,
getBackendSrv: () => ({
get: (path: string) => {
switch (path) {
case `${GRAFANA_API_ROOT}/plugins/not-installed/versions`:
case `${GRAFANA_API_ROOT}/plugins/enterprise/versions`:
return Promise.resolve([]);
case `${GRAFANA_API_ROOT}/plugins/installed/versions`:
return Promise.resolve({
items: [
{
version: '1.0.0',
createdAt: '2016-04-06T20:23:41.000Z',
},
],
});
case API_ROOT:
return Promise.resolve([
localPlugin(),
localPlugin({ id: 'installed', signature: PluginSignatureStatus.valid }),
localPlugin({ id: 'has-update', signature: PluginSignatureStatus.valid }),
localPlugin({ id: 'core', signature: PluginSignatureStatus.internal }),
]);
case `${GRAFANA_API_ROOT}/plugins/core`:
return Promise.resolve(localPlugin({ id: 'core', signature: PluginSignatureStatus.internal }));
case `${GRAFANA_API_ROOT}/plugins/not-installed`:
return Promise.resolve(remotePlugin());
case `${GRAFANA_API_ROOT}/plugins/has-update`:
return Promise.resolve(remotePlugin({ slug: 'has-update', version: '2.0.0' }));
case `${GRAFANA_API_ROOT}/plugins/installed`:
return Promise.resolve(remotePlugin({ slug: 'installed' }));
case `${GRAFANA_API_ROOT}/plugins/enterprise`:
return Promise.resolve(remotePlugin({ status: 'enterprise' }));
case `${GRAFANA_API_ROOT}/plugins`:
return Promise.resolve({
items: [
remotePlugin({ slug: 'not-installed' }),
remotePlugin({ slug: 'installed' }),
remotePlugin({ slug: 'has-update', version: '2.0.0' }),
remotePlugin({ slug: 'enterprise', status: 'enterprise' }),
],
});
default:
return Promise.reject();
}
},
}),
config: {
...original.config,
bootData: {
...original.config.bootData,
user: {
...original.config.bootData.user,
isGrafanaAdmin: true,
},
},
buildInfo: {
...original.config.buildInfo,
version: 'v7.5.0',
},
pluginAdminEnabled: true,
},
};
mockedRuntime.config.bootData.user.isGrafanaAdmin = true;
mockedRuntime.config.buildInfo.version = 'v8.1.0';
mockedRuntime.config.pluginAdminEnabled = true;
return mockedRuntime;
});
const renderPluginDetails = (pluginOverride: Partial<CatalogPlugin>): RenderResult => {
const plugin = getCatalogPluginMock(pluginOverride);
const { id } = plugin;
const props = getRouteComponentProps({ match: { params: { pluginId: id }, isExact: true, url: '', path: '' } });
const store = configureStore({
plugins: getPluginsStateMock([plugin]),
});
function setup(pluginId: string): RenderResult {
const props = getRouteComponentProps({ match: { params: { pluginId }, isExact: true, url: '', path: '' } });
const store = configureStore();
return render(
<Provider store={store}>
<PluginDetailsPage {...props} />
</Provider>
);
}
};
describe('Plugin details page', () => {
const id = 'my-plugin';
let dateNow: any;
beforeAll(() => {
@ -104,15 +52,50 @@ describe('Plugin details page', () => {
dateNow.mockRestore();
});
it('should display an overview (plugin readme) by default', async () => {
const { queryByText } = setup('not-installed');
// We are doing this very basic test to see if the API fetching and data-munging is working correctly from a high-level.
it('(SMOKE TEST) - should fetch and merge the remote and local plugin API responses correctly ', async () => {
const id = 'smoke-test-plugin';
mockPluginApis({
remote: { slug: id },
local: { id },
});
const props = getRouteComponentProps({ match: { params: { pluginId: id }, isExact: true, url: '', path: '' } });
const store = configureStore();
const { queryByText } = render(
<Provider store={store}>
<PluginDetailsPage {...props} />
</Provider>
);
await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument());
});
it('should display version history', async () => {
const { queryByText, getByText, getByRole } = setup('installed');
it('should display an overview (plugin readme) by default', async () => {
const { queryByText } = renderPluginDetails({ id });
await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument());
});
it('should display version history in case it is available', async () => {
const { queryByText, getByText, getByRole } = renderPluginDetails({
id,
details: {
links: [],
versions: [
{
version: '1.0.0',
createdAt: '2016-04-06T20:23:41.000Z',
},
],
},
});
// Check if version information is available
await waitFor(() => expect(queryByText(/version history/i)).toBeInTheDocument());
// Go to the versions tab
userEvent.click(getByText(/version history/i));
expect(
getByRole('columnheader', {
@ -136,35 +119,42 @@ describe('Plugin details page', () => {
).toBeInTheDocument();
});
it("should display install button for a plugin that isn't installed", async () => {
const { queryByRole } = setup('not-installed');
it("should display an install button for a plugin that isn't installed", async () => {
const { queryByRole } = renderPluginDetails({ id, isInstalled: false });
await waitFor(() => expect(queryByRole('button', { name: /install/i })).toBeInTheDocument());
expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
});
it('should display uninstall button for an installed plugin', async () => {
const { queryByRole } = setup('installed');
it('should display an uninstall button for an already installed plugin', async () => {
const { queryByRole } = renderPluginDetails({ id, isInstalled: true });
await waitFor(() => expect(queryByRole('button', { name: /uninstall/i })).toBeInTheDocument());
});
it('should display update and uninstall buttons for a plugin with update', async () => {
const { queryByRole } = setup('has-update');
const { queryByRole } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
// Displays an "update" button
await waitFor(() => expect(queryByRole('button', { name: /update/i })).toBeInTheDocument());
// Does not display "install" and "uninstall" buttons
expect(queryByRole('button', { name: /install/i })).toBeInTheDocument();
expect(queryByRole('button', { name: /uninstall/i })).toBeInTheDocument();
});
it('should display install button for enterprise plugins if license is valid', async () => {
it('should display an install button for enterprise plugins if license is valid', async () => {
config.licenseInfo.hasValidLicense = true;
const { queryByRole } = setup('enterprise');
const { queryByRole } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true });
await waitFor(() => expect(queryByRole('button', { name: /install/i })).toBeInTheDocument());
});
it('should not display install button for enterprise plugins if license is invalid', async () => {
config.licenseInfo.hasValidLicense = false;
const { queryByRole, queryByText } = setup('enterprise');
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true, isEnterprise: true });
await waitFor(() => expect(queryByRole('button', { name: /install/i })).not.toBeInTheDocument());
expect(queryByText(/no valid Grafana Enterprise license detected/i)).toBeInTheDocument();
@ -172,126 +162,50 @@ describe('Plugin details page', () => {
});
it('should not display install / uninstall buttons for core plugins', async () => {
const { queryByRole } = setup('core');
const { queryByRole } = renderPluginDetails({ id, isInstalled: true, isCore: true });
await waitFor(() => expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
});
it('should display install link with pluginAdminExternalManageEnabled true', async () => {
it('should display install link with `config.pluginAdminExternalManageEnabled` set to true', async () => {
config.pluginAdminExternalManageEnabled = true;
const { queryByRole } = setup('not-installed');
const { queryByRole } = renderPluginDetails({ id, isInstalled: false });
await waitFor(() => expect(queryByRole('link', { name: /install via grafana.com/i })).toBeInTheDocument());
});
it('should display uninstall link for an installed plugin with pluginAdminExternalManageEnabled true', async () => {
it('should display uninstall link for an installed plugin with `config.pluginAdminExternalManageEnabled` set to true', async () => {
config.pluginAdminExternalManageEnabled = true;
const { queryByRole } = setup('installed');
const { queryByRole } = renderPluginDetails({ id, isInstalled: true });
await waitFor(() => expect(queryByRole('link', { name: /uninstall via grafana.com/i })).toBeInTheDocument());
});
it('should display update and uninstall links for a plugin with update and pluginAdminExternalManageEnabled true', async () => {
it('should display update and uninstall links for a plugin with an available update and `config.pluginAdminExternalManageEnabled` set to true', async () => {
config.pluginAdminExternalManageEnabled = true;
const { queryByRole } = setup('has-update');
const { queryByRole } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
await waitFor(() => expect(queryByRole('link', { name: /update via grafana.com/i })).toBeInTheDocument());
expect(queryByRole('link', { name: /uninstall via grafana.com/i })).toBeInTheDocument();
});
it('should display grafana dependencies for a plugin if they are available', async () => {
const { queryByText } = setup('not-installed');
const { queryByText } = renderPluginDetails({
id,
details: {
pluginDependencies: [],
grafanaDependency: '>=8.0.0',
links: [],
},
});
// Wait for the dependencies part to be loaded
await waitFor(() => expect(queryByText(/dependencies:/i)).toBeInTheDocument());
expect(queryByText('Grafana >=7.3.0')).toBeInTheDocument();
expect(queryByText('Grafana >=8.0.0')).toBeInTheDocument();
});
});
function remotePlugin(plugin: Partial<RemotePlugin> = {}): RemotePlugin {
return {
createdAt: '2016-04-06T20:23:41.000Z',
description: 'Zabbix plugin for Grafana',
downloads: 33645089,
featured: 180,
id: 74,
typeId: 1,
typeName: 'Application',
internal: false,
links: [],
name: 'Zabbix',
orgId: 13056,
orgName: 'Alexander Zobnin',
orgSlug: 'alexanderzobnin',
orgUrl: 'https://github.com/alexanderzobnin',
url: 'https://github.com/alexanderzobnin/grafana-zabbix',
verified: false,
downloadSlug: 'alexanderzobnin-zabbix-app',
packages: {},
popularity: 0.2111,
signatureType: PluginSignatureType.community,
slug: 'alexanderzobnin-zabbix-app',
status: 'active',
typeCode: PluginType.app,
updatedAt: '2021-05-18T14:53:01.000Z',
version: '4.1.5',
versionStatus: 'active',
versionSignatureType: PluginSignatureType.community,
versionSignedByOrg: 'alexanderzobnin',
versionSignedByOrgName: 'Alexander Zobnin',
userId: 0,
readme:
'<h1>Zabbix plugin for Grafana</h1>\n<p>:copyright: 2015-2021 Alexander Zobnin alexanderzobnin@gmail.com</p>\n<p>Licensed under the Apache 2.0 License</p>',
json: {
dependencies: {
grafanaDependency: '>=7.3.0',
grafanaVersion: '7.3',
plugins: [],
},
info: {
links: [],
},
},
...plugin,
};
}
function localPlugin(plugin: Partial<LocalPlugin> = {}): LocalPlugin {
return {
name: 'Akumuli',
type: PluginType.datasource,
id: 'akumuli-datasource',
enabled: true,
pinned: false,
info: {
author: {
name: 'Eugene Lazin',
url: 'https://akumuli.org',
},
description: 'Datasource plugin for Akumuli time-series database',
links: [
{
name: 'Project site',
url: 'https://github.com/akumuli/Akumuli',
},
],
logos: {
small: 'public/plugins/akumuli-datasource/img/logo.svg.png',
large: 'public/plugins/akumuli-datasource/img/logo.svg.png',
},
build: {},
screenshots: null,
version: '1.3.12',
updated: '2019-12-19',
},
latestVersion: '1.3.12',
hasUpdate: false,
defaultNavUrl: '/plugins/akumuli-datasource/',
category: '',
state: '',
signature: PluginSignatureStatus.valid,
signatureType: PluginSignatureType.core,
signatureOrg: 'Grafana Labs',
...plugin,
};
}

View File

@ -220,3 +220,21 @@ export type ReducerState = PluginsState & {
// TODO<remove when the "plugin_admin_enabled" feature flag is removed>
export type PluginCatalogStoreState = StoreState & { plugins: ReducerState };
// The data that we receive when fetching "/api/gnet/plugins/<plugin>/versions"
export type PluginVersion = {
id: number;
pluginId: number;
pluginSlug: string;
version: string;
url: string;
commit: string;
description: string;
createdAt: string;
updatedAt?: string;
downloads: number;
verified: boolean;
status: string;
downloadSlug: string;
links: Array<{ rel: string; href: string }>;
};