From c4413f4c2e5cac9c3002129009abf03085a4fd40 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 5 Aug 2026 08:39:10 +0200 Subject: [PATCH] feat(plugin-e2e): fetch e2e-selectors from Grafana at runtime The selectors fixture fetches the data-only /public/e2e-selectors.json from the Grafana under test, reconstructs the template descriptors into functions locally (no eval) and resolves them. When the instance does not serve the file it falls back to the bundled @grafana/e2e-selectors (quiet on 404, warns on other failures). --- .../plugin-e2e/src/fixtures/selectors.test.ts | 118 ++++++++++++++++++ packages/plugin-e2e/src/fixtures/selectors.ts | 93 ++++++++++++-- .../src/selectors/reconstruct.test.ts | 44 +++++++ .../plugin-e2e/src/selectors/reconstruct.ts | 49 ++++++++ 4 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 packages/plugin-e2e/src/fixtures/selectors.test.ts create mode 100644 packages/plugin-e2e/src/selectors/reconstruct.test.ts create mode 100644 packages/plugin-e2e/src/selectors/reconstruct.ts diff --git a/packages/plugin-e2e/src/fixtures/selectors.test.ts b/packages/plugin-e2e/src/fixtures/selectors.test.ts new file mode 100644 index 0000000000..85f812627a --- /dev/null +++ b/packages/plugin-e2e/src/fixtures/selectors.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// identity resolveSelectors so we can assert which tree flowed through; tagged bundled data so we +// can tell the bundled dependency apart from the fetched, reconstructed data +vi.mock('@grafana/e2e-selectors', () => ({ + resolveSelectors: vi.fn((versioned: unknown) => versioned), + versionedComponents: { __source: 'dep-components' }, + versionedPages: { __source: 'dep-pages' }, +})); +vi.mock('../selectors/versionedConstants', () => ({ versionedConstants: { __source: 'local-constants' } })); +vi.mock('../selectors/versionedAPIs', () => ({ versionedAPIs: { __source: 'local-apis' } })); + +import { selectors } from './selectors'; + +const VALID_BODY = JSON.stringify({ + schemaVersion: 1, + versionedComponents: { __source: 'fetched-components' }, + versionedPages: { __source: 'fetched-pages' }, +}); + +function mockResponse({ status = 200, body = '' }: { status?: number; body?: string }) { + return { status: () => status, ok: () => status >= 200 && status < 300, text: async () => body }; +} + +function mockRequest(get: ReturnType) { + return { get } as never; +} + +async function runFixture(args: { grafanaVersion: string; request: never }) { + let captured: Record | undefined; + await (selectors as unknown as (a: unknown, use: (value: Record) => Promise) => Promise)( + args, + async (value) => { + captured = value; + } + ); + return captured!; +} + +describe('selectors fixture', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + beforeEach(() => { + warnSpy.mockClear(); + }); + + it('uses the runtime selectors served by Grafana when present', async () => { + const get = vi.fn().mockResolvedValue(mockResponse({ status: 200, body: VALID_BODY })); + + const result = await runFixture({ grafanaVersion: '11.0.0-200', request: mockRequest(get) }); + + expect(get).toHaveBeenCalledWith('/public/e2e-selectors.json', { maxRedirects: 0 }); + expect(result.components).toEqual({ __source: 'fetched-components' }); + expect(result.pages).toEqual({ __source: 'fetched-pages' }); + expect(result.apis).toEqual({ __source: 'local-apis' }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('falls back to the bundled dependency quietly when Grafana does not serve the file (404)', async () => { + const get = vi.fn().mockResolvedValue(mockResponse({ status: 404 })); + + const result = await runFixture({ grafanaVersion: '11.0.0-404', request: mockRequest(get) }); + + expect(result.components).toEqual({ __source: 'dep-components' }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('falls back with a warning on a server error', async () => { + const get = vi.fn().mockResolvedValue(mockResponse({ status: 503 })); + + const result = await runFixture({ grafanaVersion: '11.0.0-503', request: mockRequest(get) }); + + expect(result.components).toEqual({ __source: 'dep-components' }); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('falls back with a warning on a network error', async () => { + const get = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await runFixture({ grafanaVersion: '11.0.0-net', request: mockRequest(get) }); + + expect(result.components).toEqual({ __source: 'dep-components' }); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('falls back with a warning on invalid JSON', async () => { + const get = vi.fn().mockResolvedValue(mockResponse({ status: 200, body: 'not json' })); + + const result = await runFixture({ grafanaVersion: '11.0.0-badjson', request: mockRequest(get) }); + + expect(result.components).toEqual({ __source: 'dep-components' }); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('falls back with a warning on an unexpected schema', async () => { + const body = JSON.stringify({ schemaVersion: 2, versionedComponents: {}, versionedPages: {} }); + const get = vi.fn().mockResolvedValue(mockResponse({ status: 200, body })); + + const result = await runFixture({ grafanaVersion: '11.0.0-badschema', request: mockRequest(get) }); + + expect(result.components).toEqual({ __source: 'dep-components' }); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('shares a single fetch across concurrent fixtures for the same version', async () => { + const get = vi.fn().mockResolvedValue(mockResponse({ status: 200, body: VALID_BODY })); + const request = mockRequest(get); + + const [a, b] = await Promise.all([ + runFixture({ grafanaVersion: '11.0.0-cache', request }), + runFixture({ grafanaVersion: '11.0.0-cache', request }), + ]); + + expect(get).toHaveBeenCalledTimes(1); + expect(a.components).toEqual({ __source: 'fetched-components' }); + expect(b.components).toEqual({ __source: 'fetched-components' }); + }); +}); diff --git a/packages/plugin-e2e/src/fixtures/selectors.ts b/packages/plugin-e2e/src/fixtures/selectors.ts index cac81cd4c1..ed338fbde2 100644 --- a/packages/plugin-e2e/src/fixtures/selectors.ts +++ b/packages/plugin-e2e/src/fixtures/selectors.ts @@ -1,16 +1,95 @@ -import { TestFixture } from '@playwright/test'; +import { APIRequestContext, TestFixture } from '@playwright/test'; +import { + resolveSelectors, + versionedComponents as bundledVersionedComponents, + versionedPages as bundledVersionedPages, +} from '@grafana/e2e-selectors'; import { E2ESelectorGroups, PlaywrightArgs } from '../types'; -import { resolveSelectors, versionedComponents, versionedPages } from '@grafana/e2e-selectors'; import { versionedConstants } from '../selectors/versionedConstants'; import { versionedAPIs } from '../selectors/versionedAPIs'; +import { reconstructSelectorTree } from '../selectors/reconstruct'; type SelectorFixture = TestFixture; -export const selectors: SelectorFixture = async ({ grafanaVersion }, use) => { - await use({ - components: resolveSelectors(versionedComponents, grafanaVersion), - pages: resolveSelectors(versionedPages, grafanaVersion), +type VersionedComponents = typeof bundledVersionedComponents; +type VersionedPages = typeof bundledVersionedPages; + +// data-only selectors file served by the Grafana instance under test (see grafana/grafana +// e2e-selectors build). Used when the instance serves it; otherwise the fixture falls back to the +// selectors bundled with the installed release. +const SELECTORS_URL = '/public/e2e-selectors.json'; + +// per-worker cache keyed by grafanaVersion so concurrent fixtures share one in-flight fetch +const selectorsCache = new Map>(); + +function buildGroups( + components: VersionedComponents, + pages: VersionedPages, + grafanaVersion: string +): E2ESelectorGroups { + return { + components: resolveSelectors(components, grafanaVersion), + pages: resolveSelectors(pages, grafanaVersion), constants: resolveSelectors(versionedConstants, grafanaVersion), apis: resolveSelectors(versionedAPIs, grafanaVersion), - }); + }; +} + +// fall back to the selectors bundled with the installed @grafana/plugin-e2e release +function bundledGroups(grafanaVersion: string): E2ESelectorGroups { + return buildGroups(bundledVersionedComponents, bundledVersionedPages, grafanaVersion); +} + +async function fetchRuntimeGroups(request: APIRequestContext, grafanaVersion: string): Promise { + let response; + try { + response = await request.get(SELECTORS_URL, { maxRedirects: 0 }); + } catch (error) { + console.warn(`@grafana/plugin-e2e: failed to fetch ${SELECTORS_URL}, falling back to bundled selectors.`, error); + return bundledGroups(grafanaVersion); + } + + // 404 -> Grafana predates the feature; expected on older images, fall back quietly + if (response.status() === 404) { + return bundledGroups(grafanaVersion); + } + + if (!response.ok()) { + console.warn( + `@grafana/plugin-e2e: ${SELECTORS_URL} returned ${response.status()}, falling back to bundled selectors.` + ); + return bundledGroups(grafanaVersion); + } + + try { + const data = JSON.parse(await response.text()) as { + schemaVersion?: unknown; + versionedComponents?: unknown; + versionedPages?: unknown; + }; + if ( + data?.schemaVersion !== 1 || + typeof data.versionedComponents !== 'object' || + typeof data.versionedPages !== 'object' + ) { + throw new Error('unexpected e2e-selectors schema'); + } + const components = reconstructSelectorTree(data.versionedComponents) as VersionedComponents; + const pages = reconstructSelectorTree(data.versionedPages) as VersionedPages; + return buildGroups(components, pages, grafanaVersion); + } catch (error) { + console.warn(`@grafana/plugin-e2e: failed to read ${SELECTORS_URL}, falling back to bundled selectors.`, error); + return bundledGroups(grafanaVersion); + } +} + +export const selectors: SelectorFixture = async ({ grafanaVersion, request }, use) => { + // use the runtime selectors served by the Grafana under test when available, otherwise fall back + // to the selectors bundled with the installed release + let groups = selectorsCache.get(grafanaVersion); + if (!groups) { + groups = fetchRuntimeGroups(request, grafanaVersion); + selectorsCache.set(grafanaVersion, groups); + } + await use(await groups); }; diff --git a/packages/plugin-e2e/src/selectors/reconstruct.test.ts b/packages/plugin-e2e/src/selectors/reconstruct.test.ts new file mode 100644 index 0000000000..389a237298 --- /dev/null +++ b/packages/plugin-e2e/src/selectors/reconstruct.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; + +import { reconstructSelectorTree } from './reconstruct'; + +describe('reconstructSelectorTree', () => { + it('keeps string selectors unchanged', () => { + const tree = reconstructSelectorTree({ Comp: { sel: { '8.0.0': 'plain string' } } }); + expect(tree).toEqual({ Comp: { sel: { '8.0.0': 'plain string' } } }); + }); + + it('reconstructs a one-arg descriptor into an interpolating function', () => { + const tree = reconstructSelectorTree({ + Comp: { sel: { '8.0.0': { $template: 'data-testid option {0}', params: ['value'] } } }, + }) as any; + const fn = tree.Comp.sel['8.0.0']; + expect(typeof fn).toBe('function'); + expect(fn('X')).toBe('data-testid option X'); + }); + + it('reconstructs a two-arg descriptor', () => { + const tree = reconstructSelectorTree({ + s: { '8.0.0': { $template: 'range {0} to {1}', params: ['from', 'to'] } }, + }) as any; + expect(tree.s['8.0.0']('a', 'b')).toBe('range a to b'); + }); + + it('reconstructs a zero-arg (css) descriptor', () => { + const tree = reconstructSelectorTree({ s: { '8.0.0': { $template: '.some-class', params: [] } } }) as any; + expect(tree.s['8.0.0']()).toBe('.some-class'); + }); + + it('reconstructs a conditional (present/absent) descriptor', () => { + const tree = reconstructSelectorTree({ + s: { '11.1.0': { $template: { whenPresent: 'Options group {0}', whenAbsent: 'Options group' }, params: ['title'] } }, + }) as any; + const fn = tree.s['11.1.0']; + expect(fn('X')).toBe('Options group X'); + expect(fn('')).toBe('Options group'); + }); + + it('throws on a malformed descriptor', () => { + expect(() => reconstructSelectorTree({ s: { '8.0.0': { $template: 42, params: [] } } })).toThrow(); + }); +}); diff --git a/packages/plugin-e2e/src/selectors/reconstruct.ts b/packages/plugin-e2e/src/selectors/reconstruct.ts new file mode 100644 index 0000000000..c2b0bde2a1 --- /dev/null +++ b/packages/plugin-e2e/src/selectors/reconstruct.ts @@ -0,0 +1,49 @@ +import { VersionedSelectorGroup } from '@grafana/e2e-selectors'; + +// Reconstructs the data-only selector tree served by Grafana (see grafana/grafana e2e-selectors +// build) back into a versioned selector tree with functions, so it can be passed to resolveSelectors. +// Function selectors are serialized as template descriptors; here they become fixed local functions +// that only substitute positional {0}, {1} placeholders - no code from the fetched file is executed. + +type ConditionalTemplate = { whenPresent: string; whenAbsent: string }; +type TemplateDescriptor = { $template: string | ConditionalTemplate; params?: string[] }; + +function isDescriptor(node: object): node is TemplateDescriptor { + return '$template' in node; +} + +function fill(template: string, args: string[]): string { + return template.replace(/\{(\d+)\}/g, (_, index) => args[Number(index)] ?? ''); +} + +function reconstructDescriptor(descriptor: TemplateDescriptor): (...args: string[]) => string { + const template = descriptor.$template; + if (typeof template === 'string') { + return (...args: string[]) => fill(template, args); + } + if (template && typeof template === 'object' && typeof template.whenPresent === 'string' && typeof template.whenAbsent === 'string') { + return (...args: string[]) => (args[0] ? fill(template.whenPresent, args) : template.whenAbsent); + } + throw new Error('@grafana/plugin-e2e: malformed e2e-selectors template descriptor'); +} + +function reconstructNode(node: unknown): unknown { + if (typeof node === 'string') { + return node; + } + if (!node || typeof node !== 'object') { + throw new Error('@grafana/plugin-e2e: unexpected e2e-selectors node'); + } + if (isDescriptor(node)) { + return reconstructDescriptor(node); + } + const result: Record = {}; + for (const [key, value] of Object.entries(node)) { + result[key] = reconstructNode(value); + } + return result; +} + +export function reconstructSelectorTree(data: unknown): VersionedSelectorGroup { + return reconstructNode(data) as VersionedSelectorGroup; +}