diff --git a/.gitignore b/.gitignore index 3dfea51cdad..3b2d8f1d5d7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/devenv/datasources.yaml b/devenv/datasources.yaml index 0cfa7810670..5a05761f65e 100644 --- a/devenv/datasources.yaml +++ b/devenv/datasources.yaml @@ -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 diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/dashboard.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/dashboard.spec.ts similarity index 100% rename from e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/dashboard.ts rename to e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/dashboard.spec.ts diff --git a/e2e/test-plugins/grafana-test-datasource/CHANGELOG.md b/e2e/test-plugins/grafana-test-datasource/CHANGELOG.md new file mode 100644 index 00000000000..825c32f0d03 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/e2e/test-plugins/grafana-test-datasource/README.md b/e2e/test-plugins/grafana-test-datasource/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/e2e/test-plugins/grafana-test-datasource/components/ConfigEditor.tsx b/e2e/test-plugins/grafana-test-datasource/components/ConfigEditor.tsx new file mode 100644 index 00000000000..2c46992a5d9 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/components/ConfigEditor.tsx @@ -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 {} + +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 ( + <> + + ) => onJsonDataChange('path', e.target.value)} + value={jsonData.path} + placeholder="Enter the path, e.g. /api/v1" + width={40} + /> + + + ) => onSecureJsonDataChange('path', e.target.value)} + /> + + + ) => onJsonDataChange('switchEnabled', e.target.checked)} + /> + + + ) => onJsonDataChange('checkboxEnabled', e.target.checked)} + /> + + + + + + + + + ); +} diff --git a/e2e/test-plugins/grafana-test-datasource/datasource.ts b/e2e/test-plugins/grafana-test-datasource/datasource.ts new file mode 100644 index 00000000000..d4e61516512 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/datasource.ts @@ -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 { + baseUrl: string; + + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + this.baseUrl = instanceSettings.url!; + } + + getDefaultQuery(_: CoreApp): Partial { + 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): Promise { + 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({ + 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, + }; + } + } +} diff --git a/e2e/test-plugins/grafana-test-datasource/img/logo.svg b/e2e/test-plugins/grafana-test-datasource/img/logo.svg new file mode 100644 index 00000000000..3d284dea3af --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/img/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/e2e/test-plugins/grafana-test-datasource/module.ts b/e2e/test-plugins/grafana-test-datasource/module.ts new file mode 100644 index 00000000000..b8231ebbddb --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/module.ts @@ -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) + .setConfigEditor(ConfigEditor) + .setQueryEditor(QueryEditor); diff --git a/e2e/test-plugins/grafana-test-datasource/package.json b/e2e/test-plugins/grafana-test-datasource/package.json new file mode 100644 index 00000000000..bb61fd647f2 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/package.json @@ -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" +} diff --git a/e2e/test-plugins/grafana-test-datasource/plugin.json b/e2e/test-plugins/grafana-test-datasource/plugin.json new file mode 100644 index 00000000000..8ca25220fbd --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/plugin.json @@ -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": [] + } +} diff --git a/e2e/test-plugins/grafana-test-datasource/tests/configEditor.spec.ts b/e2e/test-plugins/grafana-test-datasource/tests/configEditor.spec.ts new file mode 100644 index 00000000000..b72ff0580b5 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/tests/configEditor.spec.ts @@ -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']); + }); +}); diff --git a/e2e/test-plugins/grafana-test-datasource/tsconfig.json b/e2e/test-plugins/grafana-test-datasource/tsconfig.json new file mode 100644 index 00000000000..40352099203 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "types": ["node", "jest", "@testing-library/jest-dom"] + }, + "extends": "@grafana/plugin-configs/tsconfig.json", + "include": ["."] +} diff --git a/e2e/test-plugins/grafana-test-datasource/types.ts b/e2e/test-plugins/grafana-test-datasource/types.ts new file mode 100644 index 00000000000..b5c8d73b031 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/types.ts @@ -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 = { + 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; +} diff --git a/e2e/test-plugins/grafana-test-datasource/webpack.config.ts b/e2e/test-plugins/grafana-test-datasource/webpack.config.ts new file mode 100644 index 00000000000..3303ed94f3c --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/webpack.config.ts @@ -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): Promise => { + 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; diff --git a/playwright.config.ts b/playwright.config.ts index d21226df069..889fa50f1c0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -97,6 +97,15 @@ export default defineConfig({ }, 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'), diff --git a/scripts/grafana-server/custom.ini b/scripts/grafana-server/custom.ini index 83bf88f1965..8be26fb511c 100644 --- a/scripts/grafana-server/custom.ini +++ b/scripts/grafana-server/custom.ini @@ -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 diff --git a/yarn.lock b/yarn.lock index 94b76f7cc43..9109258cf48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"