Skip to content

Commit 779cb8e

Browse files
sunkerCopilot
andauthored
Plugin E2E: OpenFeature support (#2408)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 30472ee commit 779cb8e

20 files changed

Lines changed: 958 additions & 41 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { PlaywrightTestArgs, TestFixture } from '@playwright/test';
2+
3+
interface BootData {
4+
version: string | undefined;
5+
namespace: string | undefined;
6+
}
7+
8+
type BootDataFixture = TestFixture<BootData, PlaywrightTestArgs>;
9+
10+
/**
11+
* Internal fixture that fetches boot data from Grafana.
12+
* This fixture is not exposed in the test API - it's only used by other fixtures
13+
* to consolidate boot data fetching to avoid creating multiple temporary pages.
14+
*/
15+
export const bootData: BootDataFixture = async ({ context }, use) => {
16+
// creates a temporary page to avoid circular dependencies between fixtures
17+
const tempPage = await context.newPage();
18+
try {
19+
await tempPage.goto('/');
20+
const bootDataSettings = await tempPage.evaluate(() => {
21+
return {
22+
version: window.grafanaBootData.settings.buildInfo.version,
23+
namespace: window.grafanaBootData.settings.namespace,
24+
};
25+
});
26+
27+
await use({
28+
version: bootDataSettings.version,
29+
namespace: bootDataSettings.namespace,
30+
});
31+
} catch (error) {
32+
console.error('@grafana/plugin-e2e: Failed to fetch boot data', error);
33+
// provide undefined values if fetch fails (fixtures will apply their own defaults)
34+
await use({ version: undefined, namespace: undefined });
35+
} finally {
36+
await tempPage.close();
37+
}
38+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { TestFixture } from '@playwright/test';
2+
import { PlaywrightArgs } from '../types';
3+
4+
type GetBooleanOpenFeatureFlagFixture = TestFixture<(flagKey: string) => Promise<boolean>, PlaywrightArgs>;
5+
6+
export const getBooleanOpenFeatureFlag: GetBooleanOpenFeatureFlagFixture = async (
7+
{ page, selectors, namespace },
8+
use
9+
) => {
10+
await use(async (flagKey: string) => {
11+
try {
12+
const url = selectors.apis.OpenFeature.ofrepSinglePath(namespace, flagKey);
13+
14+
// make the request from within the page context to use the same authentication
15+
const result = await page.evaluate(async (flagUrl) => {
16+
const response = await fetch(flagUrl);
17+
18+
if (!response.ok) {
19+
return {
20+
error: true,
21+
status: response.status,
22+
statusText: response.statusText,
23+
};
24+
}
25+
26+
const body = await response.json();
27+
return { error: false, value: body.value };
28+
}, url);
29+
30+
if (result.error) {
31+
throw new Error(`Failed to fetch OpenFeature flag "${flagKey}": ${result.status} ${result.statusText}`);
32+
}
33+
34+
const value = result.value;
35+
36+
if (typeof value !== 'boolean') {
37+
throw new Error(
38+
`Expected boolean value for flag "${flagKey}", but got ${typeof value}. Use a different getter for non-boolean flags.`
39+
);
40+
}
41+
42+
return value;
43+
} catch (error) {
44+
console.error(`@grafana/plugin-e2e: Failed to get OpenFeature flag "${flagKey}"`, error);
45+
throw error;
46+
}
47+
});
48+
};
Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { PlaywrightTestArgs, TestFixture } from '@playwright/test';
1+
import { TestFixture } from '@playwright/test';
2+
import { PlaywrightArgs } from '../types';
23

3-
type GrafanaVersion = TestFixture<string, PlaywrightTestArgs>;
4+
type GrafanaVersion = TestFixture<string, PlaywrightArgs>;
45

5-
export const grafanaVersion: GrafanaVersion = async ({ page }, use) => {
6-
let grafanaVersion = process.env.GRAFANA_VERSION ?? '';
7-
if (!grafanaVersion) {
8-
await page.goto('/');
9-
grafanaVersion = await page.evaluate('window.grafanaBootData.settings.buildInfo.version');
10-
}
6+
export const grafanaVersion: GrafanaVersion = async ({ bootData }, use) => {
7+
// plugins may override version in CI via env var
8+
const version = process.env.GRAFANA_VERSION || bootData.version || '';
119

12-
await use(grafanaVersion.replace(/\-.*/, ''));
10+
// strip version suffix (e.g., "11.0.0-pre" -> "11.0.0")
11+
await use(version.replace(/\-.*/, ''));
1312
};

packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ import { PlaywrightArgs } from '../types';
33

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

6-
export const isFeatureToggleEnabled: FeatureToggleFixture = async ({ page }, use) => {
6+
export const isLegacyFeatureToggleEnabled: FeatureToggleFixture = async ({ page }, use) => {
77
await use(async <T = object>(featureToggle: keyof T) => {
88
const featureToggles: T = await page.evaluate('window.grafanaBootData.settings.featureToggles');
99
return Boolean(featureToggles[featureToggle]);
1010
});
1111
};
1212

13-
export const isFeatureEnabled = async (page: Page, featureToggle: string) => {
13+
export const isFeatureToggleEnabled: FeatureToggleFixture = isLegacyFeatureToggleEnabled;
14+
15+
export const isLegacyFeatureEnabled = async (page: Page, featureToggle: string) => {
1416
const featureToggles: Record<string, string> = await page.evaluate('window.grafanaBootData.settings.featureToggles');
1517
return Boolean(featureToggles[featureToggle]);
1618
};
19+
20+
export const isFeatureEnabled = isLegacyFeatureEnabled;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { TestFixture } from '@playwright/test';
2+
import { PlaywrightArgs } from '../types';
3+
4+
type Namespace = TestFixture<string, PlaywrightArgs>;
5+
6+
export const namespace: Namespace = async ({ bootData }, use) => {
7+
// default to 'default' if namespace is not available
8+
await use(bootData.namespace || 'default');
9+
};
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { Page, Route } from '@playwright/test';
2+
3+
import { FeatureFlagValue, PlaywrightArgs } from '../types';
4+
5+
/**
6+
* Represents a single flag in the OFREP response
7+
*/
8+
interface OFREPFlag {
9+
key: string;
10+
value: FeatureFlagValue;
11+
reason: string;
12+
variant: string;
13+
}
14+
15+
/**
16+
* Represents the OFREP bulk evaluation response body
17+
*/
18+
interface OFREPBulkResponse {
19+
flags: OFREPFlag[];
20+
}
21+
22+
/**
23+
* Delays execution for the specified number of milliseconds
24+
*/
25+
function delay(ms: number): Promise<void> {
26+
return new Promise((resolve) => setTimeout(resolve, ms));
27+
}
28+
29+
/**
30+
* Handles the OFREP bulk evaluation endpoint by merging flags into the response
31+
*/
32+
async function handleBulkEvaluationRoute(
33+
route: Route,
34+
flags: Record<string, FeatureFlagValue>,
35+
latency: number
36+
): Promise<void> {
37+
let response: Awaited<ReturnType<Route['fetch']>> | undefined;
38+
39+
try {
40+
response = await route.fetch();
41+
42+
if (!response.ok()) {
43+
await route.fulfill({ response });
44+
return;
45+
}
46+
47+
const body: OFREPBulkResponse = await response.json();
48+
49+
// override existing flags with provided values
50+
for (const flag of body.flags) {
51+
if (flag.key in flags) {
52+
flag.value = flags[flag.key];
53+
flag.reason = 'STATIC';
54+
flag.variant = 'playwright-override';
55+
}
56+
}
57+
58+
// add any flags not present in the original response
59+
for (const [key, value] of Object.entries(flags)) {
60+
const exists = body.flags.some((f) => f.key === key);
61+
if (!exists) {
62+
body.flags.push({
63+
key,
64+
value,
65+
reason: 'STATIC',
66+
variant: 'playwright-override',
67+
});
68+
}
69+
}
70+
71+
// Apply artificial latency if specified
72+
if (latency > 0) {
73+
await delay(latency);
74+
}
75+
76+
await route.fulfill({
77+
response,
78+
body: JSON.stringify(body),
79+
headers: { 'content-type': 'application/json' },
80+
});
81+
} catch (error) {
82+
console.error('@grafana/plugin-e2e: Failed to intercept OFREP bulk evaluation', error);
83+
// fulfill with original response if available, otherwise return error response
84+
if (response) {
85+
await route.fulfill({ response });
86+
} else {
87+
await route.fulfill({
88+
status: 500,
89+
body: JSON.stringify({ error: 'Failed to intercept OFREP request' }),
90+
headers: { 'content-type': 'application/json' },
91+
});
92+
}
93+
}
94+
}
95+
96+
/**
97+
* Handles the OFREP single flag evaluation endpoint by returning the override if defined
98+
*/
99+
async function handleSingleFlagRoute(
100+
route: Route,
101+
flags: Record<string, FeatureFlagValue>,
102+
latency: number
103+
): Promise<void> {
104+
try {
105+
const url = new URL(route.request().url());
106+
const flagKey = url.pathname.split('/').pop();
107+
108+
if (flagKey && flagKey in flags) {
109+
// apply artificial latency if specified
110+
if (latency > 0) {
111+
await delay(latency);
112+
}
113+
114+
await route.fulfill({
115+
status: 200,
116+
body: JSON.stringify({
117+
key: flagKey,
118+
value: flags[flagKey],
119+
reason: 'STATIC',
120+
variant: 'playwright-override',
121+
}),
122+
headers: { 'content-type': 'application/json' },
123+
});
124+
return;
125+
}
126+
127+
// fetch the original response if we don't have an override
128+
const response = await route.fetch();
129+
await route.fulfill({ response });
130+
} catch (error) {
131+
console.error('@grafana/plugin-e2e: Failed to intercept OFREP single flag evaluation', error);
132+
// return error response since we can't continue after route.fetch()
133+
await route.fulfill({
134+
status: 500,
135+
body: JSON.stringify({ error: 'Failed to intercept OFREP request' }),
136+
headers: { 'content-type': 'application/json' },
137+
});
138+
}
139+
}
140+
141+
/**
142+
* Sets up route interception for OpenFeature OFREP endpoints
143+
*/
144+
export async function setupOpenFeatureRoutes(
145+
page: Page,
146+
openFeature: Record<string, FeatureFlagValue>,
147+
latency: number,
148+
selectors: PlaywrightArgs['selectors']
149+
): Promise<void> {
150+
console.log('@grafana/plugin-e2e: setting up OpenFeature OFREP interception', {
151+
openFeature,
152+
latency,
153+
});
154+
155+
// intercept bulk evaluation endpoint
156+
await page.route(selectors.apis.OpenFeature.ofrepBulkPattern, async (route) => {
157+
await handleBulkEvaluationRoute(route, openFeature, latency);
158+
});
159+
160+
// intercept single flag evaluation endpoint
161+
await page.route(selectors.apis.OpenFeature.ofrepSinglePattern, async (route) => {
162+
await handleSingleFlagRoute(route, openFeature, latency);
163+
});
164+
}
Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,51 @@
11
import { Page, TestFixture } from '@playwright/test';
2+
import { gte } from 'semver';
23

34
import { PlaywrightArgs } from '../types';
5+
import { setupOpenFeatureRoutes } from './openFeature';
46
import { overrideGrafanaBootData } from './scripts/overrideGrafanaBootData';
57

68
type PageFixture = TestFixture<Page, PlaywrightArgs>;
79

810
/**
911
* This fixture ensures the feature toggles defined in the Playwright config are being used in Grafana frontend.
10-
* If Grafana version >= 10.1.0, feature toggles are read from to the 'grafana.featureToggles' key in the browser's localStorage.
11-
* Otherwise, feature toggles are added directly to the window.grafanaBootData.settings.featureToggles object.
12+
*
13+
* Feature toggles are applied in two ways:
14+
* 1. Legacy: Added directly to the window.grafanaBootData.settings.featureToggles object via init script
15+
* 2. OpenFeature: Intercepted and merged into OFREP API responses (Grafana 12.1.0+)
16+
*
17+
* The `featureToggles` option uses the legacy approach only.
18+
* The `openFeature` option uses OFREP API interception and requires Grafana >= 12.1.0.
1219
*
1320
* page.addInitScript adds a script which would be evaluated in one of the following scenarios:
1421
* - Whenever the page is navigated.
1522
* - Whenever the child frame is attached or navigated. In this case, the script is evaluated in the context of the
1623
* newly attached frame.
1724
* The script is evaluated after the document was created but before any of its scripts were run.
1825
*/
19-
export const page: PageFixture = async ({ page, featureToggles, userPreferences }, use) => {
20-
if (Object.keys(featureToggles).length > 0 || Object.keys(userPreferences).length > 0) {
26+
export const page: PageFixture = async (
27+
{ page, featureToggles, openFeature, userPreferences, grafanaVersion, selectors },
28+
use
29+
) => {
30+
const hasFeatureToggles = Object.keys(featureToggles).length > 0;
31+
const hasOpenFeature = Object.keys(openFeature.flags).length > 0;
32+
const hasUserPreferences = Object.keys(userPreferences).length > 0;
33+
34+
// set up legacy feature toggle overrides via init script
35+
if (hasFeatureToggles || hasUserPreferences) {
2136
try {
2237
await page.addInitScript(overrideGrafanaBootData, { featureToggles, userPreferences });
2338
} catch (error) {
2439
console.error('Failed to set feature toggles', error);
2540
}
2641
}
2742

43+
// set up OpenFeature OFREP route interception BEFORE navigation
44+
// only runs if openFeature flags are provided and Grafana version >= 12.1.0
45+
if (hasOpenFeature && gte(grafanaVersion, '12.1.0')) {
46+
await setupOpenFeatureRoutes(page, openFeature.flags, openFeature.latency ?? 0, selectors);
47+
}
48+
2849
await page.goto('/');
2950
await use(page);
3051
};

0 commit comments

Comments
 (0)