From fbbf737c9c4ada1cfd70ba5c17665d3c3796dd5c Mon Sep 17 00:00:00 2001 From: jev forsberg Date: Mon, 4 Aug 2025 19:47:02 -0600 Subject: [PATCH] Re-add: Complete grafana-test-datasource plugin + upgrade E2E infrastructure E2E Test Plugin: - Add missing webpack.config.ts and all other plugin files - Resolves 'build-test-plugins' failure in CI - Complete E2E test plugin now buildable with NX - Plugin structure: datasource.ts, components/, tests/, webpack.config.ts, etc. E2E Infrastructure Upgrades: - Upgrade all runners to github-hosted-ubuntu-x64-large (8-cores, 32GB RAM, 300GB SSD) - Temporarily disable concurrency to bypass stuck workflow blocking - Standardized dedicated runners for better reliability vs shared ubuntu-latest instances - Should resolve intermittent E2E failures due to resource contention --- .github/workflows/pr-e2e-tests.yml | 19 ++-- .../grafana-test-datasource/CHANGELOG.md | 1 + .../grafana-test-datasource/README.md | 0 .../components/ConfigEditor.tsx | 96 +++++++++++++++++++ .../components/QueryEditor.tsx | 45 +++++++++ .../grafana-test-datasource/datasource.ts | 94 ++++++++++++++++++ .../grafana-test-datasource/img/logo.svg | 1 + .../grafana-test-datasource/module.ts | 9 ++ .../grafana-test-datasource/plugin.json | 26 +++++ .../tests/configEditor.spec.ts | 39 ++++++++ .../tests/variables.spec.ts | 13 +++ .../grafana-test-datasource/tsconfig.json | 8 ++ .../grafana-test-datasource/types.ts | 37 +++++++ .../grafana-test-datasource/variables.ts | 13 +++ .../grafana-test-datasource/webpack.config.ts | 44 +++++++++ 15 files changed, 436 insertions(+), 9 deletions(-) create mode 100644 e2e/test-plugins/grafana-test-datasource/CHANGELOG.md create mode 100644 e2e/test-plugins/grafana-test-datasource/README.md create mode 100644 e2e/test-plugins/grafana-test-datasource/components/ConfigEditor.tsx create mode 100644 e2e/test-plugins/grafana-test-datasource/components/QueryEditor.tsx create mode 100644 e2e/test-plugins/grafana-test-datasource/datasource.ts create mode 100644 e2e/test-plugins/grafana-test-datasource/img/logo.svg create mode 100644 e2e/test-plugins/grafana-test-datasource/module.ts create mode 100644 e2e/test-plugins/grafana-test-datasource/plugin.json create mode 100644 e2e/test-plugins/grafana-test-datasource/tests/configEditor.spec.ts create mode 100644 e2e/test-plugins/grafana-test-datasource/tests/variables.spec.ts create mode 100644 e2e/test-plugins/grafana-test-datasource/tsconfig.json create mode 100644 e2e/test-plugins/grafana-test-datasource/types.ts create mode 100644 e2e/test-plugins/grafana-test-datasource/variables.ts create mode 100644 e2e/test-plugins/grafana-test-datasource/webpack.config.ts diff --git a/.github/workflows/pr-e2e-tests.yml b/.github/workflows/pr-e2e-tests.yml index 3642da4a86b..baad1228376 100644 --- a/.github/workflows/pr-e2e-tests.yml +++ b/.github/workflows/pr-e2e-tests.yml @@ -7,16 +7,17 @@ on: - main - release-*.*.* -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} +# Temporarily disabled concurrency to bypass stuck workflow +# concurrency: +# group: ${{ github.workflow }}-${{ github.ref }} +# cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} permissions: {} jobs: detect-changes: name: Detect whether code changed - runs-on: ubuntu-latest + runs-on: github-hosted-ubuntu-x64-large permissions: contents: read outputs: @@ -36,7 +37,7 @@ jobs: needs: detect-changes if: needs.detect-changes.outputs.changed == 'true' name: Build & Package Grafana - runs-on: ubuntu-latest-16-cores + runs-on: github-hosted-ubuntu-x64-large permissions: contents: read outputs: @@ -64,7 +65,7 @@ jobs: needs: detect-changes if: needs.detect-changes.outputs.changed == 'true' name: Build E2E test runner - runs-on: ubuntu-latest + runs-on: github-hosted-ubuntu-x64-large permissions: contents: read outputs: @@ -121,7 +122,7 @@ jobs: path: e2e/old-arch/panels-suite flags: --flags="--env DISABLE_SCENES=true" name: ${{ matrix.suite }} - runs-on: ubuntu-latest-8-cores + runs-on: github-hosted-ubuntu-x64-large permissions: contents: read @@ -163,7 +164,7 @@ jobs: needs: - build-grafana name: A11y test - runs-on: ubuntu-latest-8-cores + runs-on: github-hosted-ubuntu-x64-large permissions: contents: read @@ -201,7 +202,7 @@ jobs: if: always() name: All E2E tests complete - runs-on: ubuntu-latest + runs-on: github-hosted-ubuntu-x64-large steps: - name: Check test suites env: 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..4ed426f4036 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/datasource.ts @@ -0,0 +1,94 @@ +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'; +import { VariableSupport } from './variables'; + +export class DataSource extends DataSourceApi { + baseUrl: string; + + constructor(instanceSettings: DataSourceInstanceSettings) { + super(instanceSettings); + this.baseUrl = instanceSettings.url!; + this.variables = new VariableSupport(); + } + + 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 { + data: [ + createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', values: [from, to], type: FieldType.time }, + { name: 'Value', values: ['A', 'B'], type: FieldType.string }, + ], + }), + ], + }; + } + + 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/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/tests/variables.spec.ts b/e2e/test-plugins/grafana-test-datasource/tests/variables.spec.ts new file mode 100644 index 00000000000..72565e5a885 --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/tests/variables.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +test('should render variable editor', async ({ variableEditPage, page }) => { + await variableEditPage.datasource.set('gdev-e2etestdatasource'); + await expect(page.getByRole('textbox', { name: 'Query Text' })).toBeVisible(); +}); + +test('create new, successful variable query', async ({ variableEditPage, page }) => { + await variableEditPage.datasource.set('gdev-e2etestdatasource'); + await page.getByRole('textbox', { name: 'Query Text' }).fill('variableQuery'); + await variableEditPage.runQuery(); + await expect(variableEditPage).toDisplayPreviews(['A', 'B']); +}); 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/variables.ts b/e2e/test-plugins/grafana-test-datasource/variables.ts new file mode 100644 index 00000000000..0abc380ef6a --- /dev/null +++ b/e2e/test-plugins/grafana-test-datasource/variables.ts @@ -0,0 +1,13 @@ +import { VariableSupportBase, VariableSupportType } from '@grafana/data'; +import { DEFAULT_QUERY, MyQuery } from './types'; +import { DataSource } from './datasource'; + +export class VariableSupport extends VariableSupportBase { + getType(): VariableSupportType { + return VariableSupportType.Datasource; + } + + getDefaultQuery(): Partial { + return DEFAULT_QUERY; + } +} 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;