diff --git a/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.test.ts b/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.test.ts new file mode 100644 index 0000000000..fb2796ad5a --- /dev/null +++ b/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.test.ts @@ -0,0 +1,53 @@ +import type { Page } from '@playwright/test'; +import { describe, it, expect, vi } from 'vitest'; + +import { isLegacyFeatureEnabled } from './isFeatureToggleEnabled'; + +/** + * Stands in for a Playwright Page. waitForFunction resolves with a handle whose jsonValue() + * returns the toggle map, or rejects to simulate the timeout. + */ +function createMockPage(behaviour: { toggles?: Record; timesOut?: boolean }) { + return { + waitForFunction: vi.fn().mockImplementation(() => { + if (behaviour.timesOut) { + return Promise.reject(new Error('Timeout 5000ms exceeded')); + } + return Promise.resolve({ jsonValue: () => Promise.resolve(behaviour.toggles) }); + }), + } as unknown as Page; +} + +describe('isLegacyFeatureEnabled', () => { + it('returns true for an enabled toggle', async () => { + const page = createMockPage({ toggles: { dashboardNewLayouts: true } }); + await expect(isLegacyFeatureEnabled(page, 'dashboardNewLayouts')).resolves.toBe(true); + }); + + it('returns false for a toggle that is absent from the map', async () => { + const page = createMockPage({ toggles: { somethingElse: true } }); + await expect(isLegacyFeatureEnabled(page, 'dashboardNewLayouts')).resolves.toBe(false); + }); + + it('waits for the toggle map rather than reading it once', async () => { + const page = createMockPage({ toggles: { dashboardNewLayouts: true } }); + await isLegacyFeatureEnabled(page, 'dashboardNewLayouts'); + expect(page.waitForFunction).toHaveBeenCalledTimes(1); + }); + + // Falling back to an empty map here would be indistinguishable from every toggle being + // disabled, so callers would silently take a legacy branch on an instance where the toggle is + // on. The timeout must stay loud. + it('rethrows when boot data never arrives rather than reporting every toggle as disabled', async () => { + const page = createMockPage({ timesOut: true }); + await expect(isLegacyFeatureEnabled(page, 'dashboardNewLayouts')).rejects.toThrow( + /featureToggles was not available within 5000ms/ + ); + }); + + it('preserves the underlying timeout as the error cause', async () => { + const page = createMockPage({ timesOut: true }); + const error = await isLegacyFeatureEnabled(page, 'dashboardNewLayouts').catch((e: unknown) => e); + expect((error as Error).cause).toEqual(new Error('Timeout 5000ms exceeded')); + }); +}); diff --git a/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts b/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts index 98d4a6d6ce..24eb43e05e 100644 --- a/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts +++ b/packages/plugin-e2e/src/fixtures/isFeatureToggleEnabled.ts @@ -3,9 +3,40 @@ import { PlaywrightArgs } from '../types'; type FeatureToggleFixture = TestFixture<(featureToggle: keyof T) => Promise, PlaywrightArgs>; +const FEATURE_TOGGLES_TIMEOUT = 5000; + +/** + * Reads the feature toggle map from boot data, waiting for it to arrive. + * + * Grafana can render the app shell before it has merged its boot data, in which case + * `settings.featureToggles` is briefly absent even though the Window type declares it as always + * present. Reading it once then throws `Cannot read properties of undefined`. + * + * Waiting removes that race. It deliberately does not fall back to an empty map when the wait + * times out, because a missing map is indistinguishable from every toggle being disabled: a + * caller would silently take its legacy branch on an instance where the toggle is enabled. A + * loud failure is the lesser evil, so the timeout is rethrown with the context needed to act + * on it. + */ +const readFeatureToggles = async (page: Page): Promise => { + try { + const featureToggles = await page.waitForFunction( + () => window.grafanaBootData?.settings?.featureToggles ?? null, + undefined, + { timeout: FEATURE_TOGGLES_TIMEOUT } + ); + return (await featureToggles.jsonValue()) as T; + } catch (error) { + throw new Error( + `@grafana/plugin-e2e: window.grafanaBootData.settings.featureToggles was not available within ${FEATURE_TOGGLES_TIMEOUT}ms`, + { cause: error } + ); + } +}; + export const isLegacyFeatureToggleEnabled: FeatureToggleFixture = async ({ page }, use) => { await use(async (featureToggle: keyof T) => { - const featureToggles: T = await page.evaluate('window.grafanaBootData.settings.featureToggles'); + const featureToggles = await readFeatureToggles(page); return Boolean(featureToggles[featureToggle]); }); }; @@ -13,7 +44,7 @@ export const isLegacyFeatureToggleEnabled: FeatureToggleFixture = async ({ page export const isFeatureToggleEnabled: FeatureToggleFixture = isLegacyFeatureToggleEnabled; export const isLegacyFeatureEnabled = async (page: Page, featureToggle: string) => { - const featureToggles: Record = await page.evaluate('window.grafanaBootData.settings.featureToggles'); + const featureToggles = await readFeatureToggles>(page); return Boolean(featureToggles[featureToggle]); };