Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c4c2b52
add open feature option
sunker Jan 20, 2026
38bba9e
wip
sunker Jan 20, 2026
30e7ded
support open feature
sunker Jan 20, 2026
f9d8cf7
wip
sunker Jan 20, 2026
bd206c2
add e2e tests
sunker Jan 21, 2026
bda0bac
store api paths in versioned selectors as they may change from versio…
sunker Jan 21, 2026
04e7e68
skip tests for older g versions
sunker Jan 21, 2026
e0e062c
add bootData and namespace fixtures; refactor grafanaVersion to use b…
sunker Jan 21, 2026
5615164
refactor tests to use versioned paths
sunker Jan 21, 2026
1ad09df
handle defaults in actual fixtures
sunker Jan 21, 2026
306132e
ensure open feature supports not only boolean flags
sunker Jan 22, 2026
5a6de69
add test that verifies all types of flags work
sunker Jan 22, 2026
93ce0bd
improve docs
sunker Jan 22, 2026
4a54c4b
cleanup comments
sunker Jan 22, 2026
3fefa78
self review
sunker Jan 22, 2026
c8437c4
Update packages/plugin-e2e/tests/as-admin-user/openfeature/openFeatur…
sunker Jan 26, 2026
27448fd
Update packages/plugin-e2e/tests/as-admin-user/openfeature/openFeatur…
sunker Jan 26, 2026
9be63c2
update test cases to use openFeature instead of featureToggles
sunker Jan 26, 2026
1ea9c18
enhance error handling in OFREP route handlers
sunker Jan 26, 2026
f2df4fb
add legacy feature toggle support and deprecate old methods
sunker Jan 26, 2026
22e5585
replace isFeatureEnabled with isLegacyFeatureEnabled in models
sunker Jan 26, 2026
3d7faa2
add getBooleanOpenFeatureFlag fixture and related tests
sunker Jan 26, 2026
6724eb9
remove redundant comments
sunker Jan 26, 2026
8e2ce4c
update getBooleanOpenFeatureFlag to use page context for API requests
sunker Jan 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/plugin-e2e/src/fixtures/bootData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { PlaywrightTestArgs, TestFixture } from '@playwright/test';

interface BootData {
version: string | undefined;
namespace: string | undefined;
}

type BootDataFixture = TestFixture<BootData, PlaywrightTestArgs>;

/**
* 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();
}
};
48 changes: 48 additions & 0 deletions packages/plugin-e2e/src/fixtures/getOpenFeatureFlag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { TestFixture } from '@playwright/test';
import { PlaywrightArgs } from '../types';

type GetBooleanOpenFeatureFlagFixture = TestFixture<(flagKey: string) => Promise<boolean>, 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;
}
});
};
17 changes: 8 additions & 9 deletions packages/plugin-e2e/src/fixtures/grafanaVersion.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { PlaywrightTestArgs, TestFixture } from '@playwright/test';
import { TestFixture } from '@playwright/test';
import { PlaywrightArgs } from '../types';

type GrafanaVersion = TestFixture<string, PlaywrightTestArgs>;
type GrafanaVersion = TestFixture<string, PlaywrightArgs>;

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(/\-.*/, ''));
};
8 changes: 6 additions & 2 deletions packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ import { PlaywrightArgs } from '../types';

type FeatureToggleFixture = TestFixture<<T = object>(featureToggle: keyof T) => Promise<boolean>, PlaywrightArgs>;

export const isFeatureToggleEnabled: FeatureToggleFixture = async ({ page }, use) => {
export const isLegacyFeatureToggleEnabled: FeatureToggleFixture = async ({ page }, use) => {
await use(async <T = object>(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<string, string> = await page.evaluate('window.grafanaBootData.settings.featureToggles');
return Boolean(featureToggles[featureToggle]);
};

export const isFeatureEnabled = isLegacyFeatureEnabled;
9 changes: 9 additions & 0 deletions packages/plugin-e2e/src/fixtures/namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { TestFixture } from '@playwright/test';
import { PlaywrightArgs } from '../types';

type Namespace = TestFixture<string, PlaywrightArgs>;

export const namespace: Namespace = async ({ bootData }, use) => {
// default to 'default' if namespace is not available
await use(bootData.namespace || 'default');
};
164 changes: 164 additions & 0 deletions packages/plugin-e2e/src/fixtures/openFeature.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, FeatureFlagValue>,
latency: number
): Promise<void> {
let response: Awaited<ReturnType<Route['fetch']>> | 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<string, FeatureFlagValue>,
latency: number
): Promise<void> {
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<string, FeatureFlagValue>,
latency: number,
selectors: PlaywrightArgs['selectors']
): Promise<void> {
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);
});
}
29 changes: 25 additions & 4 deletions packages/plugin-e2e/src/fixtures/page.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,51 @@
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<Page, PlaywrightArgs>;

/**
* 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.
* - Whenever the child frame is attached or navigated. In this case, the script is evaluated in the context of the
* 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) {
console.error('Failed to set feature toggles', error);
}
}

// 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);
};
Loading
Loading