Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
118 changes: 118 additions & 0 deletions packages/plugin-e2e/src/fixtures/selectors.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>) {
return { get } as never;
}

async function runFixture(args: { grafanaVersion: string; request: never }) {
let captured: Record<string, unknown> | undefined;
await (selectors as unknown as (a: unknown, use: (value: Record<string, unknown>) => Promise<void>) => Promise<void>)(
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' });
});
});
93 changes: 86 additions & 7 deletions packages/plugin-e2e/src/fixtures/selectors.ts
Original file line number Diff line number Diff line change
@@ -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<E2ESelectorGroups, PlaywrightArgs>;

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<string, Promise<E2ESelectorGroups>>();

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<E2ESelectorGroups> {
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);
};
44 changes: 44 additions & 0 deletions packages/plugin-e2e/src/selectors/reconstruct.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
49 changes: 49 additions & 0 deletions packages/plugin-e2e/src/selectors/reconstruct.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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;
}
Loading