Test plugins: Add datasource test plugin with field tests (#95472)

* add new test plugin

* add some field validation tests

* update lockfile

* fix bad test file name
This commit is contained in:
Erik Sundell 2024-11-01 08:25:27 +01:00 committed by GitHub
parent f3bdf4455c
commit c29ed503db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 500 additions and 1 deletions

2
.gitignore vendored
View File

@ -224,3 +224,5 @@ public/app/plugins/**/dist/
# Mock service worker used for fake API responses in frontend development
public/mockServiceWorker.js
/e2e/test-plugins/*/dist

View File

@ -321,3 +321,11 @@ datasources:
access: proxy
url: http://localhost:4040
editable: false
- name: gdev-e2etestdatasource
type: grafana-e2etest-datasource
uid: gdev-e2etest-datasource
access: proxy
url: http://localhost:4040
editable: false

View File

@ -0,0 +1 @@
# Changelog

View File

@ -0,0 +1,96 @@
import { ChangeEvent } from 'react';
import { Checkbox, InlineField, InlineSwitch, Input, SecretInput, Select } from '@grafana/ui';
import { DataSourcePluginOptionsEditorProps, SelectableValue, toOption } from '@grafana/data';
import { MyDataSourceOptions, MySecureJsonData } from '../types';
interface Props extends DataSourcePluginOptionsEditorProps<MyDataSourceOptions, MySecureJsonData> {}
export function ConfigEditor(props: Props) {
const { onOptionsChange, options } = props;
const { jsonData, secureJsonFields, secureJsonData } = options;
const onJsonDataChange = (key: string, value: string | number | boolean) => {
onOptionsChange({
...options,
jsonData: {
...jsonData,
[key]: value,
},
});
};
// Secure field (only sent to the backend)
const onSecureJsonDataChange = (key: string, value: string | number) => {
onOptionsChange({
...options,
secureJsonData: {
[key]: value,
},
});
};
const onResetAPIKey = () => {
onOptionsChange({
...options,
secureJsonFields: {
...options.secureJsonFields,
apiKey: false,
},
secureJsonData: {
...options.secureJsonData,
apiKey: '',
},
});
};
return (
<>
<InlineField label="Path" labelWidth={14} interactive tooltip={'Json field returned to frontend'}>
<Input
id="config-editor-path"
onChange={(e: ChangeEvent<HTMLInputElement>) => onJsonDataChange('path', e.target.value)}
value={jsonData.path}
placeholder="Enter the path, e.g. /api/v1"
width={40}
/>
</InlineField>
<InlineField label="API Key" labelWidth={14} interactive tooltip={'Secure json field (backend only)'}>
<SecretInput
required
id="config-editor-api-key"
isConfigured={secureJsonFields.apiKey}
value={secureJsonData?.apiKey}
placeholder="Enter your API key"
width={40}
onReset={onResetAPIKey}
onChange={(e: ChangeEvent<HTMLInputElement>) => onSecureJsonDataChange('path', e.target.value)}
/>
</InlineField>
<InlineField label="Switch Enabled">
<InlineSwitch
width={40}
label="Switch Enabled"
value={jsonData.switchEnabled ?? false}
onChange={(e: ChangeEvent<HTMLInputElement>) => onJsonDataChange('switchEnabled', e.target.checked)}
/>
</InlineField>
<InlineField label="Checkbox Enabled">
<Checkbox
width={40}
id="config-checkbox-enabled"
value={jsonData.checkboxEnabled}
onChange={(e: ChangeEvent<HTMLInputElement>) => onJsonDataChange('checkboxEnabled', e.target.checked)}
/>
</InlineField>
<InlineField label="Auth type">
<Select
width={40}
inputId="config-auth-type"
value={jsonData.authType ?? 'keys'}
options={['keys', 'credentials'].map(toOption)}
onChange={(e: SelectableValue<string>) => onJsonDataChange('authType', e.value!)}
/>
</InlineField>
</>
);
}

View File

@ -0,0 +1,45 @@
import { ChangeEvent } from 'react';
import { InlineField, Input, Stack } from '@grafana/ui';
import { QueryEditorProps } from '@grafana/data';
import { DataSource } from '../datasource';
import { MyDataSourceOptions, MyQuery } from '../types';
type Props = QueryEditorProps<DataSource, MyQuery, MyDataSourceOptions>;
export function QueryEditor({ query, onChange, onRunQuery }: Props) {
const onQueryTextChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange({ ...query, queryText: event.target.value });
};
const onConstantChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange({ ...query, constant: parseFloat(event.target.value) });
// executes the query
onRunQuery();
};
const { queryText, constant } = query;
return (
<Stack gap={0}>
<InlineField label="Constant">
<Input
id="query-editor-constant"
onChange={onConstantChange}
value={constant}
width={8}
type="number"
step="0.1"
/>
</InlineField>
<InlineField label="Query Text" labelWidth={16} tooltip="Not used yet">
<Input
id="query-editor-query-text"
onChange={onQueryTextChange}
value={queryText || ''}
required
placeholder="Enter a query"
/>
</InlineField>
</Stack>
);
}

View File

@ -0,0 +1,93 @@
import { getBackendSrv, isFetchError } from '@grafana/runtime';
import {
CoreApp,
DataQueryRequest,
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
createDataFrame,
FieldType,
} from '@grafana/data';
import { MyQuery, MyDataSourceOptions, DEFAULT_QUERY, DataSourceResponse } from './types';
import { lastValueFrom } from 'rxjs';
export class DataSource extends DataSourceApi<MyQuery, MyDataSourceOptions> {
baseUrl: string;
constructor(instanceSettings: DataSourceInstanceSettings<MyDataSourceOptions>) {
super(instanceSettings);
this.baseUrl = instanceSettings.url!;
}
getDefaultQuery(_: CoreApp): Partial<MyQuery> {
return DEFAULT_QUERY;
}
filterQuery(query: MyQuery): boolean {
// if no query has been provided, prevent the query from being executed
return !!query.queryText;
}
async query(options: DataQueryRequest<MyQuery>): Promise<DataQueryResponse> {
const { range } = options;
const from = range!.from.valueOf();
const to = range!.to.valueOf();
// Return a constant for each query.
const data = options.targets.map((target) => {
return createDataFrame({
refId: target.refId,
fields: [
{ name: 'Time', values: [from, to], type: FieldType.time },
{ name: 'Value', values: [target.constant, target.constant], type: FieldType.number },
],
});
});
return { data };
}
async request(url: string, params?: string) {
const response = getBackendSrv().fetch<DataSourceResponse>({
url: `${this.baseUrl}${url}${params?.length ? `?${params}` : ''}`,
});
return lastValueFrom(response);
}
/**
* Checks whether we can connect to the API.
*/
async testDatasource() {
const defaultErrorMessage = 'Cannot connect to API';
try {
const response = await this.request('/health');
if (response.status === 200) {
return {
status: 'success',
message: 'Success',
};
} else {
return {
status: 'error',
message: response.statusText ? response.statusText : defaultErrorMessage,
};
}
} catch (err) {
let message = '';
if (typeof err === 'string') {
message = err;
} else if (isFetchError(err)) {
message = 'Fetch error: ' + (err.statusText ? err.statusText : defaultErrorMessage);
if (err.data && err.data.error && err.data.error.code) {
message += ': ' + err.data.error.code + '. ' + err.data.error.message;
}
}
return {
status: 'error',
message,
};
}
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,9 @@
import { DataSourcePlugin } from '@grafana/data';
import { DataSource } from './datasource';
import { ConfigEditor } from './components/ConfigEditor';
import { QueryEditor } from './components/QueryEditor';
import { MyQuery, MyDataSourceOptions } from './types';
export const plugin = new DataSourcePlugin<DataSource, MyQuery, MyDataSourceOptions>(DataSource)
.setConfigEditor(ConfigEditor)
.setQueryEditor(QueryEditor);

View File

@ -0,0 +1,48 @@
{
"name": "@test-plugins/grafana-e2etest-datasource",
"version": "11.4.0-pre",
"private": true,
"scripts": {
"build": "webpack -c ./webpack.config.ts --env production",
"dev": "webpack -w -c ./webpack.config.ts --env development",
"typecheck": "tsc --noEmit",
"lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx ."
},
"author": "Grafana",
"license": "Apache-2.0",
"devDependencies": {
"@grafana/eslint-config": "7.0.0",
"@grafana/plugin-configs": "11.4.0-pre",
"@types/lodash": "4.17.7",
"@types/node": "20.14.14",
"@types/prismjs": "1.26.4",
"@types/react": "18.3.3",
"@types/react-dom": "18.2.25",
"@types/semver": "7.5.8",
"@types/uuid": "9.0.8",
"glob": "10.4.1",
"ts-node": "10.9.2",
"typescript": "5.5.4",
"webpack": "5.95.0",
"webpack-merge": "5.10.0"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@emotion/css": "11.11.2",
"@grafana/data": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "^6.22.0",
"rxjs": "7.8.1",
"tslib": "2.6.3"
},
"peerDependencies": {
"@grafana/runtime": "*"
},
"packageManager": "yarn@4.4.0"
}

View File

@ -0,0 +1,26 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "datasource",
"name": "Test",
"id": "grafana-e2etest-datasource",
"metrics": true,
"info": {
"description": "",
"author": {
"name": "Grafana"
},
"keywords": ["datasource"],
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"links": [],
"screenshots": [],
"version": "%VERSION%",
"updated": "%TODAY%"
},
"dependencies": {
"grafanaDependency": ">=10.4.0",
"plugins": []
}
}

View File

@ -0,0 +1,39 @@
import { test, expect, DataSourceConfigPage } from '@grafana/plugin-e2e';
// The following tests verify that label and input field association is working correctly.
// If these tests break, e2e tests in external plugins will break too.
test.describe('config editor ', () => {
let configPage: DataSourceConfigPage;
test.beforeEach(async ({ createDataSourceConfigPage }) => {
configPage = await createDataSourceConfigPage({ type: 'grafana-e2etest-datasource' });
});
test('text input field', async ({ page }) => {
const field = page.getByRole('textbox', { name: 'API key' });
await expect(field).toBeEmpty();
await field.fill('test text');
await expect(field).toHaveValue('test text');
});
test('switch field', async ({ page }) => {
const field = page.getByLabel('Switch Enabled');
await expect(field).not.toBeChecked();
await field.check();
await expect(field).toBeChecked();
});
test('checkbox field', async ({ page }) => {
const field = page.getByRole('checkbox', { name: 'Checkbox Enabled' });
await expect(field).not.toBeChecked();
await field.check({ force: true });
await expect(field).toBeChecked();
});
test('select field', async ({ page, selectors }) => {
const field = page.getByRole('combobox', { name: 'Auth type' });
await field.click();
const option = selectors.components.Select.option;
await expect(configPage.getByGrafanaSelector(option)).toHaveText(['keys', 'credentials']);
});
});

View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"types": ["node", "jest", "@testing-library/jest-dom"]
},
"extends": "@grafana/plugin-configs/tsconfig.json",
"include": ["."]
}

View File

@ -0,0 +1,37 @@
import { DataSourceJsonData } from '@grafana/data';
import { DataQuery } from '@grafana/schema';
export interface MyQuery extends DataQuery {
queryText?: string;
constant: number;
}
export const DEFAULT_QUERY: Partial<MyQuery> = {
constant: 6.5,
};
export interface DataPoint {
Time: number;
Value: number;
}
export interface DataSourceResponse {
datapoints: DataPoint[];
}
/**
* These are options configured for each DataSource instance
*/
export interface MyDataSourceOptions extends DataSourceJsonData {
switchEnabled: boolean;
checkboxEnabled: boolean;
authType: string;
path?: string;
}
/**
* Value that is used in the backend, but never sent over HTTP to the frontend
*/
export interface MySecureJsonData {
apiKey?: string;
}

View File

@ -0,0 +1,44 @@
import CopyWebpackPlugin from 'copy-webpack-plugin';
import grafanaConfig from '@grafana/plugin-configs/webpack.config';
import { mergeWithCustomize, unique } from 'webpack-merge';
import { Configuration } from 'webpack';
function skipFiles(f: string): boolean {
if (f.includes('/dist/')) {
// avoid copying files already in dist
return false;
}
if (f.includes('/node_modules/')) {
// avoid copying tsconfig.json
return false;
}
if (f.includes('/package.json')) {
// avoid copying package.json
return false;
}
return true;
}
const config = async (env: Record<string, unknown>): Promise<Configuration> => {
const baseConfig = await grafanaConfig(env);
const customConfig = {
plugins: [
new CopyWebpackPlugin({
patterns: [
// To `compiler.options.output`
{ from: 'README.md', to: '.', force: true },
{ from: 'plugin.json', to: '.' },
{ from: 'CHANGELOG.md', to: '.', force: true },
{ from: '**/*.json', to: '.', filter: skipFiles },
{ from: '**/*.svg', to: '.', noErrorOnMissing: true, filter: skipFiles }, // Optional
],
}),
],
};
return mergeWithCustomize({
customizeArray: unique('plugins', ['CopyPlugin'], (plugin) => plugin.constructor && plugin.constructor.name),
})(baseConfig, customConfig);
};
export default config;

View File

@ -97,6 +97,15 @@ export default defineConfig<PluginOptions>({
},
dependencies: ['authenticate'],
},
{
name: 'grafana-e2etest-datasource',
testDir: 'e2e/test-plugins/grafana-test-datasource',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/admin.json',
},
dependencies: ['authenticate'],
},
{
name: 'cloudwatch',
testDir: path.join(testDirRoot, '/cloudwatch'),

View File

@ -8,7 +8,7 @@ enable_frontend_sandbox_for_plugins = sandbox-app-test,sandbox-test-datasource,s
enable = publicDashboards
[plugins]
allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app,grafana-extensionexample3-app
allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app,grafana-extensionexample3-app,grafana-e2etest-datasource
[database]
type=sqlite3

View File

@ -9334,6 +9334,39 @@ __metadata:
languageName: unknown
linkType: soft
"@test-plugins/grafana-e2etest-datasource@workspace:e2e/test-plugins/grafana-test-datasource":
version: 0.0.0-use.local
resolution: "@test-plugins/grafana-e2etest-datasource@workspace:e2e/test-plugins/grafana-test-datasource"
dependencies:
"@emotion/css": "npm:11.11.2"
"@grafana/data": "workspace:*"
"@grafana/eslint-config": "npm:7.0.0"
"@grafana/plugin-configs": "npm:11.4.0-pre"
"@grafana/runtime": "workspace:*"
"@grafana/schema": "workspace:*"
"@grafana/ui": "workspace:*"
"@types/lodash": "npm:4.17.7"
"@types/node": "npm:20.14.14"
"@types/prismjs": "npm:1.26.4"
"@types/react": "npm:18.3.3"
"@types/react-dom": "npm:18.2.25"
"@types/semver": "npm:7.5.8"
"@types/uuid": "npm:9.0.8"
glob: "npm:10.4.1"
react: "npm:18.2.0"
react-dom: "npm:18.2.0"
react-router-dom: "npm:^6.22.0"
rxjs: "npm:7.8.1"
ts-node: "npm:10.9.2"
tslib: "npm:2.6.3"
typescript: "npm:5.5.4"
webpack: "npm:5.95.0"
webpack-merge: "npm:5.10.0"
peerDependencies:
"@grafana/runtime": "*"
languageName: unknown
linkType: soft
"@testing-library/dom@npm:10.4.0, @testing-library/dom@npm:>=7":
version: 10.4.0
resolution: "@testing-library/dom@npm:10.4.0"