diff --git a/packages/plugin-e2e/src/fixtures/bootData.ts b/packages/plugin-e2e/src/fixtures/bootData.ts new file mode 100644 index 0000000000..d17f848911 --- /dev/null +++ b/packages/plugin-e2e/src/fixtures/bootData.ts @@ -0,0 +1,38 @@ +import { PlaywrightTestArgs, TestFixture } from '@playwright/test'; + +interface BootData { + version: string | undefined; + namespace: string | undefined; +} + +type BootDataFixture = TestFixture; + +/** + * Internal fixture that fetches boot data from Grafana. + * This fixture is not exposed in the test API - it's only used by other fixtures + * to consolidate boot data fetching to avoid creating multiple temporary pages. + */ +export const bootData: BootDataFixture = async ({ context }, use) => { + // creates a temporary page to avoid circular dependencies between fixtures + const tempPage = await context.newPage(); + try { + await tempPage.goto('/'); + const bootDataSettings = await tempPage.evaluate(() => { + return { + version: window.grafanaBootData.settings.buildInfo.version, + namespace: window.grafanaBootData.settings.namespace, + }; + }); + + await use({ + version: bootDataSettings.version, + namespace: bootDataSettings.namespace, + }); + } catch (error) { + console.error('@grafana/plugin-e2e: Failed to fetch boot data', error); + // provide undefined values if fetch fails (fixtures will apply their own defaults) + await use({ version: undefined, namespace: undefined }); + } finally { + await tempPage.close(); + } +}; diff --git a/packages/plugin-e2e/src/fixtures/getOpenFeatureFlag.ts b/packages/plugin-e2e/src/fixtures/getOpenFeatureFlag.ts new file mode 100644 index 0000000000..d6dcd1d3bf --- /dev/null +++ b/packages/plugin-e2e/src/fixtures/getOpenFeatureFlag.ts @@ -0,0 +1,48 @@ +import { TestFixture } from '@playwright/test'; +import { PlaywrightArgs } from '../types'; + +type GetBooleanOpenFeatureFlagFixture = TestFixture<(flagKey: string) => Promise, PlaywrightArgs>; + +export const getBooleanOpenFeatureFlag: GetBooleanOpenFeatureFlagFixture = async ( + { page, selectors, namespace }, + use +) => { + await use(async (flagKey: string) => { + try { + const url = selectors.apis.OpenFeature.ofrepSinglePath(namespace, flagKey); + + // make the request from within the page context to use the same authentication + const result = await page.evaluate(async (flagUrl) => { + const response = await fetch(flagUrl); + + if (!response.ok) { + return { + error: true, + status: response.status, + statusText: response.statusText, + }; + } + + const body = await response.json(); + return { error: false, value: body.value }; + }, url); + + if (result.error) { + throw new Error(`Failed to fetch OpenFeature flag "${flagKey}": ${result.status} ${result.statusText}`); + } + + const value = result.value; + + if (typeof value !== 'boolean') { + throw new Error( + `Expected boolean value for flag "${flagKey}", but got ${typeof value}. Use a different getter for non-boolean flags.` + ); + } + + return value; + } catch (error) { + console.error(`@grafana/plugin-e2e: Failed to get OpenFeature flag "${flagKey}"`, error); + throw error; + } + }); +}; diff --git a/packages/plugin-e2e/src/fixtures/grafanaVersion.ts b/packages/plugin-e2e/src/fixtures/grafanaVersion.ts index 5554110c6a..5055c055df 100644 --- a/packages/plugin-e2e/src/fixtures/grafanaVersion.ts +++ b/packages/plugin-e2e/src/fixtures/grafanaVersion.ts @@ -1,13 +1,12 @@ -import { PlaywrightTestArgs, TestFixture } from '@playwright/test'; +import { TestFixture } from '@playwright/test'; +import { PlaywrightArgs } from '../types'; -type GrafanaVersion = TestFixture; +type GrafanaVersion = TestFixture; -export const grafanaVersion: GrafanaVersion = async ({ page }, use) => { - let grafanaVersion = process.env.GRAFANA_VERSION ?? ''; - if (!grafanaVersion) { - await page.goto('/'); - grafanaVersion = await page.evaluate('window.grafanaBootData.settings.buildInfo.version'); - } +export const grafanaVersion: GrafanaVersion = async ({ bootData }, use) => { + // plugins may override version in CI via env var + const version = process.env.GRAFANA_VERSION || bootData.version || ''; - await use(grafanaVersion.replace(/\-.*/, '')); + // strip version suffix (e.g., "11.0.0-pre" -> "11.0.0") + await use(version.replace(/\-.*/, '')); }; diff --git a/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts b/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts index 4de75c2ba3..98d4a6d6ce 100644 --- a/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts +++ b/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts @@ -3,14 +3,18 @@ import { PlaywrightArgs } from '../types'; type FeatureToggleFixture = TestFixture<(featureToggle: keyof T) => Promise, PlaywrightArgs>; -export const isFeatureToggleEnabled: FeatureToggleFixture = async ({ page }, use) => { +export const isLegacyFeatureToggleEnabled: FeatureToggleFixture = async ({ page }, use) => { await use(async (featureToggle: keyof T) => { const featureToggles: T = await page.evaluate('window.grafanaBootData.settings.featureToggles'); return Boolean(featureToggles[featureToggle]); }); }; -export const isFeatureEnabled = async (page: Page, featureToggle: string) => { +export const isFeatureToggleEnabled: FeatureToggleFixture = isLegacyFeatureToggleEnabled; + +export const isLegacyFeatureEnabled = async (page: Page, featureToggle: string) => { const featureToggles: Record = await page.evaluate('window.grafanaBootData.settings.featureToggles'); return Boolean(featureToggles[featureToggle]); }; + +export const isFeatureEnabled = isLegacyFeatureEnabled; diff --git a/packages/plugin-e2e/src/fixtures/namespace.ts b/packages/plugin-e2e/src/fixtures/namespace.ts new file mode 100644 index 0000000000..3dcad1924c --- /dev/null +++ b/packages/plugin-e2e/src/fixtures/namespace.ts @@ -0,0 +1,9 @@ +import { TestFixture } from '@playwright/test'; +import { PlaywrightArgs } from '../types'; + +type Namespace = TestFixture; + +export const namespace: Namespace = async ({ bootData }, use) => { + // default to 'default' if namespace is not available + await use(bootData.namespace || 'default'); +}; diff --git a/packages/plugin-e2e/src/fixtures/openFeature.ts b/packages/plugin-e2e/src/fixtures/openFeature.ts new file mode 100644 index 0000000000..0cabe162d8 --- /dev/null +++ b/packages/plugin-e2e/src/fixtures/openFeature.ts @@ -0,0 +1,164 @@ +import { Page, Route } from '@playwright/test'; + +import { FeatureFlagValue, PlaywrightArgs } from '../types'; + +/** + * Represents a single flag in the OFREP response + */ +interface OFREPFlag { + key: string; + value: FeatureFlagValue; + reason: string; + variant: string; +} + +/** + * Represents the OFREP bulk evaluation response body + */ +interface OFREPBulkResponse { + flags: OFREPFlag[]; +} + +/** + * Delays execution for the specified number of milliseconds + */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Handles the OFREP bulk evaluation endpoint by merging flags into the response + */ +async function handleBulkEvaluationRoute( + route: Route, + flags: Record, + latency: number +): Promise { + let response: Awaited> | undefined; + + try { + response = await route.fetch(); + + if (!response.ok()) { + await route.fulfill({ response }); + return; + } + + const body: OFREPBulkResponse = await response.json(); + + // override existing flags with provided values + for (const flag of body.flags) { + if (flag.key in flags) { + flag.value = flags[flag.key]; + flag.reason = 'STATIC'; + flag.variant = 'playwright-override'; + } + } + + // add any flags not present in the original response + for (const [key, value] of Object.entries(flags)) { + const exists = body.flags.some((f) => f.key === key); + if (!exists) { + body.flags.push({ + key, + value, + reason: 'STATIC', + variant: 'playwright-override', + }); + } + } + + // Apply artificial latency if specified + if (latency > 0) { + await delay(latency); + } + + await route.fulfill({ + response, + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + }); + } catch (error) { + console.error('@grafana/plugin-e2e: Failed to intercept OFREP bulk evaluation', error); + // fulfill with original response if available, otherwise return error response + if (response) { + await route.fulfill({ response }); + } else { + await route.fulfill({ + status: 500, + body: JSON.stringify({ error: 'Failed to intercept OFREP request' }), + headers: { 'content-type': 'application/json' }, + }); + } + } +} + +/** + * Handles the OFREP single flag evaluation endpoint by returning the override if defined + */ +async function handleSingleFlagRoute( + route: Route, + flags: Record, + latency: number +): Promise { + try { + const url = new URL(route.request().url()); + const flagKey = url.pathname.split('/').pop(); + + if (flagKey && flagKey in flags) { + // apply artificial latency if specified + if (latency > 0) { + await delay(latency); + } + + await route.fulfill({ + status: 200, + body: JSON.stringify({ + key: flagKey, + value: flags[flagKey], + reason: 'STATIC', + variant: 'playwright-override', + }), + headers: { 'content-type': 'application/json' }, + }); + return; + } + + // fetch the original response if we don't have an override + const response = await route.fetch(); + await route.fulfill({ response }); + } catch (error) { + console.error('@grafana/plugin-e2e: Failed to intercept OFREP single flag evaluation', error); + // return error response since we can't continue after route.fetch() + await route.fulfill({ + status: 500, + body: JSON.stringify({ error: 'Failed to intercept OFREP request' }), + headers: { 'content-type': 'application/json' }, + }); + } +} + +/** + * Sets up route interception for OpenFeature OFREP endpoints + */ +export async function setupOpenFeatureRoutes( + page: Page, + openFeature: Record, + latency: number, + selectors: PlaywrightArgs['selectors'] +): Promise { + console.log('@grafana/plugin-e2e: setting up OpenFeature OFREP interception', { + openFeature, + latency, + }); + + // intercept bulk evaluation endpoint + await page.route(selectors.apis.OpenFeature.ofrepBulkPattern, async (route) => { + await handleBulkEvaluationRoute(route, openFeature, latency); + }); + + // intercept single flag evaluation endpoint + await page.route(selectors.apis.OpenFeature.ofrepSinglePattern, async (route) => { + await handleSingleFlagRoute(route, openFeature, latency); + }); +} diff --git a/packages/plugin-e2e/src/fixtures/page.ts b/packages/plugin-e2e/src/fixtures/page.ts index 83474530ac..09314c9cc6 100644 --- a/packages/plugin-e2e/src/fixtures/page.ts +++ b/packages/plugin-e2e/src/fixtures/page.ts @@ -1,14 +1,21 @@ import { Page, TestFixture } from '@playwright/test'; +import { gte } from 'semver'; import { PlaywrightArgs } from '../types'; +import { setupOpenFeatureRoutes } from './openFeature'; import { overrideGrafanaBootData } from './scripts/overrideGrafanaBootData'; type PageFixture = TestFixture; /** * This fixture ensures the feature toggles defined in the Playwright config are being used in Grafana frontend. - * If Grafana version >= 10.1.0, feature toggles are read from to the 'grafana.featureToggles' key in the browser's localStorage. - * Otherwise, feature toggles are added directly to the window.grafanaBootData.settings.featureToggles object. + * + * Feature toggles are applied in two ways: + * 1. Legacy: Added directly to the window.grafanaBootData.settings.featureToggles object via init script + * 2. OpenFeature: Intercepted and merged into OFREP API responses (Grafana 12.1.0+) + * + * The `featureToggles` option uses the legacy approach only. + * The `openFeature` option uses OFREP API interception and requires Grafana >= 12.1.0. * * page.addInitScript adds a script which would be evaluated in one of the following scenarios: * - Whenever the page is navigated. @@ -16,8 +23,16 @@ type PageFixture = TestFixture; * newly attached frame. * The script is evaluated after the document was created but before any of its scripts were run. */ -export const page: PageFixture = async ({ page, featureToggles, userPreferences }, use) => { - if (Object.keys(featureToggles).length > 0 || Object.keys(userPreferences).length > 0) { +export const page: PageFixture = async ( + { page, featureToggles, openFeature, userPreferences, grafanaVersion, selectors }, + use +) => { + const hasFeatureToggles = Object.keys(featureToggles).length > 0; + const hasOpenFeature = Object.keys(openFeature.flags).length > 0; + const hasUserPreferences = Object.keys(userPreferences).length > 0; + + // set up legacy feature toggle overrides via init script + if (hasFeatureToggles || hasUserPreferences) { try { await page.addInitScript(overrideGrafanaBootData, { featureToggles, userPreferences }); } catch (error) { @@ -25,6 +40,12 @@ export const page: PageFixture = async ({ page, featureToggles, userPreferences } } + // set up OpenFeature OFREP route interception BEFORE navigation + // only runs if openFeature flags are provided and Grafana version >= 12.1.0 + if (hasOpenFeature && gte(grafanaVersion, '12.1.0')) { + await setupOpenFeatureRoutes(page, openFeature.flags, openFeature.latency ?? 0, selectors); + } + await page.goto('/'); await use(page); }; diff --git a/packages/plugin-e2e/src/index.ts b/packages/plugin-e2e/src/index.ts index a0f0309051..8c67c7a965 100644 --- a/packages/plugin-e2e/src/index.ts +++ b/packages/plugin-e2e/src/index.ts @@ -1,6 +1,13 @@ import { test as base, expect as baseExpect, Locator } from '@playwright/test'; -import { AlertPageOptions, AlertVariant, ContainTextOptions, PluginFixture, PluginOptions } from './types'; +import { + AlertPageOptions, + AlertVariant, + ContainTextOptions, + InternalFixtures, + PluginFixture, + PluginOptions, +} from './types'; import { annotationEditPage } from './fixtures/annotationEditPage'; import { grafanaAPIClient } from './fixtures/grafanaAPIClient'; import { createDataSource } from './fixtures/commands/createDataSource'; @@ -20,8 +27,11 @@ import { readProvisionedDataSource } from './fixtures/commands/readProvisionedDa import { readProvisionedAlertRule } from './fixtures/commands/readProvisionedAlertRule'; import { dashboardPage } from './fixtures/dashboardPage'; import { explorePage } from './fixtures/explorePage'; +import { bootData } from './fixtures/bootData'; import { grafanaVersion } from './fixtures/grafanaVersion'; -import { isFeatureToggleEnabled } from './fixtures/isFeatureToggleEnabled'; +import { namespace } from './fixtures/namespace'; +import { isFeatureToggleEnabled, isLegacyFeatureToggleEnabled } from './fixtures/isFeatureToggleEnabled'; +import { getBooleanOpenFeatureFlag } from './fixtures/getOpenFeatureFlag'; import { page } from './fixtures/page'; import { panelEditPage } from './fixtures/panelEditPage'; import { selectors as e2eSelectors } from './fixtures/selectors'; @@ -65,9 +75,19 @@ export { AppPage } from './models/pages/AppPage'; // types export * from './types'; -export const test = base.extend({ +// helper functions +export { isLegacyFeatureEnabled, isFeatureEnabled } from './fixtures/isFeatureToggleEnabled'; + +// first extend with internal fixtures (not exposed to tests) +const testWithInternal = base.extend({ + bootData, +}); + +// then extend with public fixtures +export const test = testWithInternal.extend({ selectors: e2eSelectors, grafanaVersion, + namespace, login, grafanaAPIClient, createDataSourceConfigPage, @@ -84,6 +104,8 @@ export const test = base.extend({ readProvisionedAlertRule, readProvisionedDashboard, isFeatureToggleEnabled, + isLegacyFeatureToggleEnabled, + getBooleanOpenFeatureFlag, createUser, gotoDashboardPage, gotoPanelEditPage, @@ -115,6 +137,10 @@ declare global { grafanaBootData: { settings: { featureToggles: Record; + buildInfo: { + version: string; + }; + namespace: string; }; }; } diff --git a/packages/plugin-e2e/src/models/pages/AlertRuleEditPage.ts b/packages/plugin-e2e/src/models/pages/AlertRuleEditPage.ts index 7c5660df66..a84bf6dc11 100644 --- a/packages/plugin-e2e/src/models/pages/AlertRuleEditPage.ts +++ b/packages/plugin-e2e/src/models/pages/AlertRuleEditPage.ts @@ -3,7 +3,7 @@ import { AlertRuleArgs, NavigateOptions, PluginTestCtx, RequestOptions } from '. import { GrafanaPage } from './GrafanaPage'; import { AlertRuleQuery } from '../components/AlertRuleQuery'; import { expect } from '@playwright/test'; -import { isFeatureEnabled } from '../../fixtures/isFeatureToggleEnabled'; +import { isLegacyFeatureEnabled } from '../../fixtures/isFeatureToggleEnabled'; const QUERY_AND_EXPRESSION_STEP_ID = '2'; export class AlertRuleEditPage extends GrafanaPage { @@ -42,7 +42,7 @@ export class AlertRuleEditPage extends GrafanaPage { } async isAdvancedModeSupported() { - const alertingQueryAndExpressionsStepMode = await isFeatureEnabled( + const alertingQueryAndExpressionsStepMode = await isLegacyFeatureEnabled( this.ctx.page, 'alertingQueryAndExpressionsStepMode' ); diff --git a/packages/plugin-e2e/src/models/pages/DashboardPage.ts b/packages/plugin-e2e/src/models/pages/DashboardPage.ts index 023befc1b4..7f1f3f9354 100644 --- a/packages/plugin-e2e/src/models/pages/DashboardPage.ts +++ b/packages/plugin-e2e/src/models/pages/DashboardPage.ts @@ -5,7 +5,7 @@ import { GrafanaPage } from './GrafanaPage'; import { PanelEditPage } from './PanelEditPage'; import { TimeRange } from '../components/TimeRange'; import { Panel } from '../components/Panel'; -import { isFeatureEnabled } from '../../fixtures/isFeatureToggleEnabled'; +import { isLegacyFeatureEnabled } from '../../fixtures/isFeatureToggleEnabled'; export class DashboardPage extends GrafanaPage { dataSourcePicker: any; @@ -87,7 +87,7 @@ export class DashboardPage extends GrafanaPage { async addPanel(): Promise { const { components, pages, constants } = this.ctx.selectors; - const scenesEnabled = await isFeatureEnabled(this.ctx.page, 'dashboardScene'); + const scenesEnabled = await isLegacyFeatureEnabled(this.ctx.page, 'dashboardScene'); // In scenes powered dashboards, one needs to click the edit button before adding a new panel in already existing dashboards if (scenesEnabled && this.dashboard?.uid) { diff --git a/packages/plugin-e2e/src/options.ts b/packages/plugin-e2e/src/options.ts index d87cdac3b3..8f2b78ca37 100644 --- a/packages/plugin-e2e/src/options.ts +++ b/packages/plugin-e2e/src/options.ts @@ -10,6 +10,10 @@ export const DEFAULT_ADMIN_USER: User = { export const options: Fixtures<{}, PluginOptions> = { userPreferences: [{}, { option: true, scope: 'worker' }], featureToggles: [{}, { option: true, scope: 'worker' }], + openFeature: [ + { flags: {}, latency: 0 }, + { option: true, scope: 'worker' }, + ], provisioningRootDir: [path.join(process.cwd(), 'provisioning'), { option: true, scope: 'worker' }], user: [DEFAULT_ADMIN_USER, { option: true, scope: 'worker' }], grafanaAPICredentials: [DEFAULT_ADMIN_USER, { option: true, scope: 'worker' }], diff --git a/packages/plugin-e2e/src/selectors/versionedAPIs.ts b/packages/plugin-e2e/src/selectors/versionedAPIs.ts index 6c61dca58f..d9552dc06d 100644 --- a/packages/plugin-e2e/src/selectors/versionedAPIs.ts +++ b/packages/plugin-e2e/src/selectors/versionedAPIs.ts @@ -43,6 +43,22 @@ export const versionedAPIs = { [MIN_GRAFANA_VERSION]: (pluginId: string) => `/api/plugins/${pluginId}/settings`, }, }, + OpenFeature: { + ofrepBulkPattern: { + '12.1.0': '**/apis/features.grafana.app/**/ofrep/v*/evaluate/flags', + }, + ofrepSinglePattern: { + '12.1.0': '**/apis/features.grafana.app/**/ofrep/v*/evaluate/flags/*', + }, + ofrepBulkPath: { + '12.1.0': (namespace = 'default') => + `/apis/features.grafana.app/v0alpha1/namespaces/${namespace}/ofrep/v1/evaluate/flags`, + }, + ofrepSinglePath: { + '12.1.0': (namespace = 'default', flagKey: string) => + `/apis/features.grafana.app/v0alpha1/namespaces/${namespace}/ofrep/v1/evaluate/flags/${flagKey}`, + }, + }, } satisfies VersionedSelectorGroup; export type VersionedAPIs = typeof versionedAPIs; diff --git a/packages/plugin-e2e/src/types.ts b/packages/plugin-e2e/src/types.ts index d4cca43203..b7fffb6cf9 100644 --- a/packages/plugin-e2e/src/types.ts +++ b/packages/plugin-e2e/src/types.ts @@ -23,6 +23,12 @@ import { VariablePage } from './models/pages/VariablePage'; import { VersionedAPIs } from './selectors/versionedAPIs'; import { VersionedConstants } from './selectors/versionedConstants'; +/** + * Value types supported by OpenFeature flags. + * Aligns with the OpenFeature specification. + */ +export type FeatureFlagValue = boolean | string | number | object; + export type PluginOptions = { /** * When using the readProvisioning fixture, files will be read from this directory. If no directory is provided, @@ -39,28 +45,72 @@ export type PluginOptions = { provisioningRootDir: string; /** * Optionally, you can add or override feature toggles. - * The feature toggles you specify here will only work in the frontend. If you need a feature toggle to work across the entire stack, you - * need to need to enable the feature in the Grafana config. Also see https://grafana.com/developers/plugin-tools/e2e-test-a-plugin/feature-toggles + * This option only supports boolean values and is primarily used for legacy feature toggles + * (via `window.grafanaBootData.settings.featureToggles`). + * + * **For OpenFeature-based flags, use the `openFeature` option instead.** + * The `openFeature` option supports multi-type values (boolean, string, number, object) + * as required by the OpenFeature specification. * - * To override feature toggles globally in the playwright.config.ts file: + * If you need a feature toggle to work across the entire stack, you need to enable the feature in the Grafana config. + * Also see https://grafana.com/developers/plugin-tools/e2e-test-a-plugin/feature-toggles + * + * @example + * ```typescript + * // Override feature toggles globally in playwright.config.ts * export default defineConfig({ - use: { - featureToggles: { - exploreMixedDatasource: true, - redshiftAsyncQueryDataSupport: false - }, - }, - }); - * - * To override feature toggles for tests in a certain file: - test.use({ - featureToggles: { - exploreMixedDatasource: true, - }, + * use: { + * featureToggles: { + * exploreMixedDatasource: true, + * redshiftAsyncQueryDataSupport: false + * }, + * }, * }); + * + * // Override feature toggles for tests in a specific file + * test.use({ + * featureToggles: { + * exploreMixedDatasource: true, + * }, + * }); + * ``` */ featureToggles: Record; + /** + * OpenFeature configuration for flag overrides and network simulation. + * Flags will be intercepted via OFREP API and merged with backend flags. + * + * Use this for any feature flags that use the OpenFeature system in Grafana. + * This option supports all OpenFeature value types: boolean, string, number and object. + * + * This only affects frontend flag evaluation via OFREP API. It does not affect + * backend feature flags (GOFF) or server-side behavior. If you need feature flags to work + * across the entire stack, you must enable them in the Grafana configuration. + * + * For legacy feature toggles (window.grafanaBootData.settings.featureToggles), + * use the `featureToggles` option instead. + * + * @example + * ```typescript + * test.use({ + * openFeature: { + * flags: { + * enableNewUI: true, // boolean + * themeColor: "blue", // string + * maxRetries: 3, // number + * apiConfig: { tier: "premium" }, // object + * }, + * latency: 200, // optional: artificial latency in ms for OFREP responses + * }, + * }); + * ``` + */ + openFeature: { + flags: Record; + latency?: number; + }; + /** * Optionally, you can add or override user preferences for the Grafana user. * The user preferences you specify here will be applied to window.grafanaBootData.user.preferences object. @@ -109,6 +159,14 @@ export type PluginFixture = { */ grafanaVersion: string; + /** + * The Grafana namespace/tenant id that was detected when the test runner was started. + * + * The namespace will be picked from window.grafanaBootData.settings.namespace. + * Defaults to 'default' if not available. + */ + namespace: string; + /** * The E2E selectors to use for the current version of Grafana. * See https://grafana.com/developers/plugin-tools/e2e-test-a-plugin/selecting-elements#grafana-end-to-end-selectors for more information. @@ -217,9 +275,34 @@ export type PluginFixture = { /** * Function that checks if a feature toggle is enabled. Only works for frontend feature toggles. + * Only works for legacy feature toggles (window.grafanaBootData.settings.featureToggles). + * @deprecated Use isLegacyFeatureToggleEnabled instead. For OpenFeature flags, use getBooleanOpenFeatureFlag. */ isFeatureToggleEnabled(featureToggle: keyof T): Promise; + /** + * Checks if a legacy feature toggle is enabled. + * This only checks window.grafanaBootData.settings.featureToggles (legacy system). + * For OpenFeature flags, use getBooleanOpenFeatureFlag instead. + */ + isLegacyFeatureToggleEnabled(featureToggle: keyof T): Promise; + + /** + * Gets the value of a boolean OpenFeature flag by calling the OFREP API. + * This retrieves the current flag value from Grafana's OpenFeature system. + * + * Note: This requires Grafana >= 12.1.0 (when OpenFeature OFREP was introduced). + * + * @example + * ```typescript + * test('should check OpenFeature flag', async ({ getBooleanOpenFeatureFlag }) => { + * const isEnabled = await getBooleanOpenFeatureFlag('myFeatureFlag'); + * expect(isEnabled).toBe(true); + * }); + * ``` + */ + getBooleanOpenFeatureFlag(flagKey: string): Promise; + /** * Client that allows you to use certain endpoints in the Grafana http API. * @@ -350,11 +433,28 @@ export type PluginTestCtx = { grafanaVersion: string; selectors: E2ESelectorGrou 'page' | 'request' >; +/** + * Internal fixtures that are not exposed in the test API. + * These fixtures can be used by other fixtures but not by tests directly. + */ +export type InternalFixtures = { + /** + * Boot data fetched from Grafana. + * Used internally by grafanaVersion and namespace fixtures. + * @internal + */ + bootData: { + version: string | undefined; + namespace: string | undefined; + }; +}; + /** * Playwright args used when defining fixtures */ export type PlaywrightArgs = PluginFixture & PluginOptions & + InternalFixtures & PlaywrightTestArgs & PlaywrightTestOptions & PlaywrightWorkerArgs & diff --git a/packages/plugin-e2e/tests/as-admin-user/datasource/feature-toggles/queryEditor.tlsEnabled.spec.ts b/packages/plugin-e2e/tests/as-admin-user/datasource/feature-toggles/queryEditor.tlsEnabled.spec.ts index 29835919bd..d9fbb925df 100644 --- a/packages/plugin-e2e/tests/as-admin-user/datasource/feature-toggles/queryEditor.tlsEnabled.spec.ts +++ b/packages/plugin-e2e/tests/as-admin-user/datasource/feature-toggles/queryEditor.tlsEnabled.spec.ts @@ -13,9 +13,9 @@ test.use({ }, }); -test('should set feature toggles correctly', async ({ isFeatureToggleEnabled }) => { - expect(await isFeatureToggleEnabled(TRUTHY_CUSTOM_TOGGLE)).toBeTruthy(); - expect(await isFeatureToggleEnabled(FALSY_CUSTOM_TOGGLE)).toBeFalsy(); +test('should set feature toggles correctly', async ({ isLegacyFeatureToggleEnabled }) => { + expect(await isLegacyFeatureToggleEnabled(TRUTHY_CUSTOM_TOGGLE)).toBeTruthy(); + expect(await isLegacyFeatureToggleEnabled(FALSY_CUSTOM_TOGGLE)).toBeFalsy(); }); test('should display TLS enabled field when tlsEnabled feature toggle is set to true', async ({ diff --git a/packages/plugin-e2e/tests/as-admin-user/openfeature/getBooleanOpenFeatureFlag.spec.ts b/packages/plugin-e2e/tests/as-admin-user/openfeature/getBooleanOpenFeatureFlag.spec.ts new file mode 100644 index 0000000000..c3c328b3ff --- /dev/null +++ b/packages/plugin-e2e/tests/as-admin-user/openfeature/getBooleanOpenFeatureFlag.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '../../../src'; +import { lt } from 'semver'; + +test.use({ + openFeature: { + flags: { + booleanFlagTrue: true, + booleanFlagFalse: false, + stringFlag: 'enabled', + numberFlag: 42, + }, + }, +}); + +test.describe('getBooleanOpenFeatureFlag fixture', () => { + test('should retrieve boolean flag values', async ({ getBooleanOpenFeatureFlag, grafanaVersion }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + const flagTrue = await getBooleanOpenFeatureFlag('booleanFlagTrue'); + const flagFalse = await getBooleanOpenFeatureFlag('booleanFlagFalse'); + + expect(flagTrue).toBe(true); + expect(flagFalse).toBe(false); + }); + + test('should throw error for non-boolean flags', async ({ getBooleanOpenFeatureFlag, grafanaVersion }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + await expect(getBooleanOpenFeatureFlag('stringFlag')).rejects.toThrow( + /Expected boolean value for flag "stringFlag", but got string/ + ); + await expect(getBooleanOpenFeatureFlag('numberFlag')).rejects.toThrow( + /Expected boolean value for flag "numberFlag", but got number/ + ); + }); + + test('should throw error for non-existent flags', async ({ getBooleanOpenFeatureFlag, grafanaVersion }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + await expect(getBooleanOpenFeatureFlag('nonExistentFlag')).rejects.toThrow(/Failed to fetch OpenFeature flag/); + }); +}); diff --git a/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-compatibility.spec.ts b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-compatibility.spec.ts new file mode 100644 index 0000000000..57a2184d28 --- /dev/null +++ b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-compatibility.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from '../../../src'; +import { lt } from 'semver'; + +// no featureToggles specified - should work unchanged +test('should work without featureToggles option', async ({ page, grafanaVersion }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + // simply verify the page loads without errors + await page.goto('/'); + await expect(page).toHaveURL(/.*\//); +}); diff --git a/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-latency.spec.ts b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-latency.spec.ts new file mode 100644 index 0000000000..6ac93456d1 --- /dev/null +++ b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-latency.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '../../../src'; +import { lt } from 'semver'; + +const LATENCY_MS = 200; + +test.use({ + openFeature: { + flags: { + latencyTestFlag: true, + }, + latency: LATENCY_MS, + }, +}); + +test('should apply artificial latency to OFREP responses', async ({ page, grafanaVersion, selectors, namespace }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + const startTime = Date.now(); + + // make a request to the single flag endpoint which will be intercepted + const flagUrl = `${selectors.apis.OpenFeature.ofrepSinglePath(namespace)}/latencyTestFlag`; + + const response = await page.evaluate(async (url) => { + try { + const res = await fetch(url); + if (res.ok) { + return res.json(); + } + return null; + } catch { + return null; + } + }, flagUrl); + + const elapsed = Date.now() - startTime; + + if (response) { + // verify the response is correct + expect(response.key).toBe('latencyTestFlag'); + expect(response.value).toBe(true); + + // verify the latency was applied (allow some margin for timing variance) + expect(elapsed).toBeGreaterThanOrEqual(LATENCY_MS - 50); + } +}); diff --git a/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-multi-type.spec.ts b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-multi-type.spec.ts new file mode 100644 index 0000000000..c7fbe3ca8f --- /dev/null +++ b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-multi-type.spec.ts @@ -0,0 +1,244 @@ +import { expect, test } from '../../../src'; +import { lt } from 'semver'; + +test.use({ + openFeature: { + flags: { + // boolean flags + booleanFlagTrue: true, + booleanFlagFalse: false, + // string flags + stringFlag: 'enabled', + stringFlagEmpty: '', + // number flags + numberFlag: 42, + numberFlagFloat: 3.14, + numberFlagZero: 0, + numberFlagNegative: -1, + // object flags + objectFlag: { tier: 'free', maxRetries: 3 }, + objectFlagNested: { config: { api: { timeout: 5000, retries: 3 }, features: ['a', 'b'] } }, + objectFlagArray: [1, 2, 3], + }, + latency: 0, + }, +}); + +test('should support all OpenFeature value types in bulk evaluation', async ({ + page, + grafanaVersion, + selectors, + namespace, +}) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + const responsePromise = page.waitForResponse( + (response) => + response.url().includes(selectors.apis.OpenFeature.ofrepBulkPath(namespace)) && !response.url().endsWith('/'), + { timeout: 5000 } + ); + + await page.goto('/'); + + try { + const response = await responsePromise; + const body = await response.json(); + + // helper to find flag by key + const findFlag = (key: string) => body.flags?.find((f: { key: string }) => f.key === key); + + // boolean flags + const booleanTrue = findFlag('booleanFlagTrue'); + expect(booleanTrue?.value).toBe(true); + expect(booleanTrue?.reason).toBe('STATIC'); + expect(booleanTrue?.variant).toBe('playwright-override'); + + const booleanFalse = findFlag('booleanFlagFalse'); + expect(booleanFalse?.value).toBe(false); + + // string flags + const stringFlag = findFlag('stringFlag'); + expect(stringFlag?.value).toBe('enabled'); + expect(stringFlag?.reason).toBe('STATIC'); + + const stringEmpty = findFlag('stringFlagEmpty'); + expect(stringEmpty?.value).toBe(''); + + // number flags + const numberFlag = findFlag('numberFlag'); + expect(numberFlag?.value).toBe(42); + + const numberFloat = findFlag('numberFlagFloat'); + expect(numberFloat?.value).toBe(3.14); + + const numberZero = findFlag('numberFlagZero'); + expect(numberZero?.value).toBe(0); + + const numberNegative = findFlag('numberFlagNegative'); + expect(numberNegative?.value).toBe(-1); + + // object flags + const objectFlag = findFlag('objectFlag'); + expect(objectFlag?.value).toEqual({ tier: 'free', maxRetries: 3 }); + + const objectNested = findFlag('objectFlagNested'); + expect(objectNested?.value).toEqual({ + config: { api: { timeout: 5000, retries: 3 }, features: ['a', 'b'] }, + }); + + const objectArray = findFlag('objectFlagArray'); + expect(objectArray?.value).toEqual([1, 2, 3]); + } catch (error) { + console.log('OFREP endpoint not called - OpenFeature may not be enabled', error); + } +}); + +test('should support boolean flag in single evaluation', async ({ page, grafanaVersion, selectors, namespace }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + const flagUrl = `${selectors.apis.OpenFeature.ofrepSinglePath(namespace)}/booleanFlagTrue`; + + const response = await page.evaluate(async (url) => { + try { + const res = await fetch(url); + if (res.ok) { + return res.json(); + } + return null; + } catch { + return null; + } + }, flagUrl); + + if (response) { + expect(response.key).toBe('booleanFlagTrue'); + expect(response.value).toBe(true); + expect(response.reason).toBe('STATIC'); + expect(response.variant).toBe('playwright-override'); + } +}); + +test('should support string flag in single evaluation', async ({ page, grafanaVersion, selectors, namespace }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + const flagUrl = `${selectors.apis.OpenFeature.ofrepSinglePath(namespace)}/stringFlag`; + + const response = await page.evaluate(async (url) => { + try { + const res = await fetch(url); + if (res.ok) { + return res.json(); + } + return null; + } catch { + return null; + } + }, flagUrl); + + if (response) { + expect(response.key).toBe('stringFlag'); + expect(response.value).toBe('enabled'); + expect(response.reason).toBe('STATIC'); + } +}); + +test('should support number flag in single evaluation', async ({ page, grafanaVersion, selectors, namespace }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + const flagUrl = `${selectors.apis.OpenFeature.ofrepSinglePath(namespace)}/numberFlag`; + + const response = await page.evaluate(async (url) => { + try { + const res = await fetch(url); + if (res.ok) { + return res.json(); + } + return null; + } catch { + return null; + } + }, flagUrl); + + if (response) { + expect(response.key).toBe('numberFlag'); + expect(response.value).toBe(42); + expect(response.reason).toBe('STATIC'); + } +}); + +test('should support number zero in single evaluation', async ({ page, grafanaVersion, selectors, namespace }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + const flagUrl = `${selectors.apis.OpenFeature.ofrepSinglePath(namespace)}/numberFlagZero`; + + const response = await page.evaluate(async (url) => { + try { + const res = await fetch(url); + if (res.ok) { + return res.json(); + } + return null; + } catch { + return null; + } + }, flagUrl); + + if (response) { + expect(response.key).toBe('numberFlagZero'); + expect(response.value).toBe(0); + } +}); + +test('should support object flag in single evaluation', async ({ page, grafanaVersion, selectors, namespace }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + const flagUrl = `${selectors.apis.OpenFeature.ofrepSinglePath(namespace)}/objectFlag`; + + const response = await page.evaluate(async (url) => { + try { + const res = await fetch(url); + if (res.ok) { + return res.json(); + } + return null; + } catch { + return null; + } + }, flagUrl); + + if (response) { + expect(response.key).toBe('objectFlag'); + expect(response.value).toEqual({ tier: 'free', maxRetries: 3 }); + expect(response.reason).toBe('STATIC'); + } +}); + +test('should support nested object flag in single evaluation', async ({ + page, + grafanaVersion, + selectors, + namespace, +}) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + const flagUrl = `${selectors.apis.OpenFeature.ofrepSinglePath(namespace)}/objectFlagNested`; + + const response = await page.evaluate(async (url) => { + try { + const res = await fetch(url); + if (res.ok) { + return res.json(); + } + return null; + } catch { + return null; + } + }, flagUrl); + + if (response) { + expect(response.key).toBe('objectFlagNested'); + expect(response.value).toEqual({ + config: { api: { timeout: 5000, retries: 3 }, features: ['a', 'b'] }, + }); + } +}); diff --git a/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-single.spec.ts b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-single.spec.ts new file mode 100644 index 0000000000..e97ece4a07 --- /dev/null +++ b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature-single.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from '../../../src'; +import { lt } from 'semver'; + +test.use({ + openFeature: { + flags: { + singleTestFlag: true, + }, + latency: 0, + }, +}); + +test('should intercept single flag evaluation endpoint', async ({ page, grafanaVersion, selectors, namespace }) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + // make a request to the single flag endpoint which will be intercepted + const flagUrl = `${selectors.apis.OpenFeature.ofrepSinglePath(namespace)}/singleTestFlag`; + + const singleFlagResponse = await page.evaluate(async (url) => { + try { + const response = await fetch(url); + if (response.ok) { + return response.json(); + } + return null; + } catch { + return null; + } + }, flagUrl); + + if (singleFlagResponse) { + expect(singleFlagResponse.key).toBe('singleTestFlag'); + expect(singleFlagResponse.value).toBe(true); + expect(singleFlagResponse.reason).toBe('STATIC'); + expect(singleFlagResponse.variant).toBe('playwright-override'); + } +}); diff --git a/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature.spec.ts b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature.spec.ts new file mode 100644 index 0000000000..be1e0e378e --- /dev/null +++ b/packages/plugin-e2e/tests/as-admin-user/openfeature/openFeature.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from '../../../src'; +import { lt } from 'semver'; + +test.use({ + openFeature: { + flags: { + testFlagTrue: true, + testFlagFalse: false, + }, + }, +}); + +test('should intercept OFREP bulk evaluation and override flags via openFeature', async ({ + page, + grafanaVersion, + selectors, + namespace, +}) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + // set up a listener to capture the OFREP response + const responsePromise = page.waitForResponse( + (response) => + response.url().includes(selectors.apis.OpenFeature.ofrepBulkPath(namespace)) && !response.url().endsWith('/'), + { timeout: 5000 } + ); + + // trigger a navigation that would cause OpenFeature to fetch flags + await page.goto('/'); + + // wait for the OFREP response (may not happen if OpenFeature is not enabled in the Grafana instance) + try { + const response = await responsePromise; + const body = await response.json(); + + // response should contain our overridden flags from openFeature + const testFlagTrue = body.flags?.find((f: { key: string }) => f.key === 'testFlagTrue'); + const testFlagFalse = body.flags?.find((f: { key: string }) => f.key === 'testFlagFalse'); + + if (testFlagTrue) { + expect(testFlagTrue.value).toBe(true); + expect(testFlagTrue.reason).toBe('STATIC'); + expect(testFlagTrue.variant).toBe('playwright-override'); + } + + if (testFlagFalse) { + expect(testFlagFalse.value).toBe(false); + expect(testFlagFalse.reason).toBe('STATIC'); + expect(testFlagFalse.variant).toBe('playwright-override'); + } + } catch { + console.log('OFREP endpoint not called - OpenFeature may not be enabled'); + } +}); + +test('should merge custom openFeature flags with backend default flags', async ({ + page, + grafanaVersion, + selectors, + namespace, +}) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + const responsePromise = page.waitForResponse( + (response) => + response.url().includes(selectors.apis.OpenFeature.ofrepBulkPath(namespace)) && !response.url().endsWith('/'), + { timeout: 5000 } + ); + + // wait for the OFREP response + try { + const response = await responsePromise; + const body = await response.json(); + + // define how many custom flags we set + const customFlagCount = 2; // testFlagTrue and testFlagFalse + + // verify we have more flags than just our custom ones (backend flags should be present) + expect(body.flags.length).toBeGreaterThan(customFlagCount); + + // custom flags are present and overridden + const customFlags = body.flags.filter((f: { variant: string }) => f.variant === 'playwright-override'); + expect(customFlags.length).toBeGreaterThanOrEqual(customFlagCount); + + // backend flags (without playwright-override variant) are still present + const backendFlags = body.flags.filter((f: { variant: string }) => f.variant !== 'playwright-override'); + expect(backendFlags.length).toBeGreaterThan(0); + + // our specific custom flags have the correct values + const testFlagTrue = body.flags.find((f: { key: string }) => f.key === 'testFlagTrue'); + const testFlagFalse = body.flags.find((f: { key: string }) => f.key === 'testFlagFalse'); + + expect(testFlagTrue?.value).toBe(true); + expect(testFlagTrue?.variant).toBe('playwright-override'); + + expect(testFlagFalse?.value).toBe(false); + expect(testFlagFalse?.variant).toBe('playwright-override'); + } catch { + console.log('OFREP endpoint not called - OpenFeature may not be enabled'); + } +}); + +test('should retrieve flag values using getBooleanOpenFeatureFlag fixture', async ({ + getBooleanOpenFeatureFlag, + grafanaVersion, +}) => { + test.skip(lt(grafanaVersion, '12.1.0'), 'OpenFeature OFREP was introduced in Grafana 12.1.0'); + + // should retrieve our overridden flags + const testFlagTrue = await getBooleanOpenFeatureFlag('testFlagTrue'); + const testFlagFalse = await getBooleanOpenFeatureFlag('testFlagFalse'); + + expect(testFlagTrue).toBe(true); + expect(testFlagFalse).toBe(false); +});