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; +}