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
3 changes: 3 additions & 0 deletions .github/workflows/playwright-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ jobs:

- name: Run Playwright tests
id: run-tests
env:
# dogfood runtime e2e-selectors here before shipping the default to all consumers
PLUGIN_E2E_RUNTIME_SELECTORS: 'true'
run: npm run playwright:test --w @grafana/plugin-e2e

- name: Upload e2e test summary
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ jobs:

- name: Run Playwright tests
id: run-tests
env:
# dogfood runtime e2e-selectors here before shipping the default to all consumers
PLUGIN_E2E_RUNTIME_SELECTORS: 'true'
run: npm run playwright:test --w @grafana/plugin-e2e

- name: Upload e2e test summary
Expand Down
15 changes: 14 additions & 1 deletion packages/plugin-e2e/src/fixtures/bootData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { PlaywrightTestArgs, TestFixture } from '@playwright/test';
interface BootData {
version: string | undefined;
namespace: string | undefined;
// absolute URL where this instance serves the data-only e2e-selectors file, derived from the asset
// base (origin in single-binary, CDN in multi-tenant). undefined when it can't be derived.
selectorsUrl: string | undefined;
}

type BootDataFixture = TestFixture<BootData, PlaywrightTestArgs>;
Expand All @@ -18,20 +21,30 @@ export const bootData: BootDataFixture = async ({ context }, use) => {
try {
await tempPage.goto('/');
const bootDataSettings = await tempPage.evaluate(() => {
// e2e-selectors.json is emitted into the frontend build output next to the JS bundles, so its
// URL is the bundle directory with the filename swapped. resolving against document.baseURI
// yields an absolute URL for both single-binary (origin-relative bundles) and multi-tenant
// (CDN-absolute bundles).
const jsFilePath = window.grafanaBootData?.assets?.jsFiles?.[0]?.filePath;
const selectorsUrl = jsFilePath
? new URL(jsFilePath.replace(/[^/]+$/, 'e2e-selectors.json'), document.baseURI).href
: undefined;
return {
version: window.grafanaBootData.settings.buildInfo.version,
namespace: window.grafanaBootData.settings.namespace,
selectorsUrl,
};
});

await use({
version: bootDataSettings.version,
namespace: bootDataSettings.namespace,
selectorsUrl: bootDataSettings.selectorsUrl,
});
} catch (error) {
console.error('@grafana/plugin-e2e: Failed to fetch boot data', error);
// provide undefined values if fetch fails (fixtures will apply their own defaults)
await use({ version: undefined, namespace: undefined });
await use({ version: undefined, namespace: undefined, selectorsUrl: undefined });
} finally {
await tempPage.close();
}
Expand Down
155 changes: 155 additions & 0 deletions packages/plugin-e2e/src/fixtures/selectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { afterEach, 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;
}

// URL the bootData fixture would derive from the instance's asset base (origin in single-binary)
const SELECTORS_URL = 'http://grafana.test/public/build/e2e-selectors.json';

async function runFixture(args: { grafanaVersion: string; request: never; selectorsUrl?: string | undefined }) {
const { grafanaVersion, request } = args;
// key-presence, not a default, so an explicit `selectorsUrl: undefined` is honored
const selectorsUrl = 'selectorsUrl' in args ? args.selectorsUrl : SELECTORS_URL;
let captured: Record<string, unknown> | undefined;
await (selectors as unknown as (a: unknown, use: (value: Record<string, unknown>) => Promise<void>) => Promise<void>)(
{ grafanaVersion, request, bootData: { version: grafanaVersion, namespace: 'default', selectorsUrl } },
async (value) => {
captured = value;
}
);
return captured!;
}

describe('selectors fixture', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

beforeEach(() => {
warnSpy.mockClear();
// enable the runtime path for these tests; the default (toggle off) is covered separately below
process.env.PLUGIN_E2E_RUNTIME_SELECTORS = 'true';
});

afterEach(() => {
delete process.env.PLUGIN_E2E_RUNTIME_SELECTORS;
});

it('uses the bundled selectors without fetching when the runtime toggle is off', async () => {
delete process.env.PLUGIN_E2E_RUNTIME_SELECTORS;
const get = vi.fn();

const result = await runFixture({ grafanaVersion: '11.0.0-off', request: mockRequest(get) });

expect(get).not.toHaveBeenCalled();
expect(result.components).toEqual({ __source: 'dep-components' });
expect(warnSpy).not.toHaveBeenCalled();
});

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(SELECTORS_URL, { 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 the URL cannot be derived from bootData', async () => {
const get = vi.fn();

const result = await runFixture({
grafanaVersion: '11.0.0-nourl',
request: mockRequest(get),
selectorsUrl: undefined,
});

expect(get).not.toHaveBeenCalled();
expect(result.components).toEqual({ __source: 'dep-components' });
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' });
});
});
111 changes: 104 additions & 7 deletions packages/plugin-e2e/src/fixtures/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,113 @@
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;

// per-worker cache keyed by grafanaVersion so concurrent fixtures share one in-flight fetch
const selectorsCache = new Map<string, Promise<E2ESelectorGroups>>();

// opt-in toggle while the runtime path is validated in plugin-tools' own Playwright workflows. when
// unset, the fixture uses the bundled selectors as before. remove once runtime selectors ship to all
// consumers.
function runtimeSelectorsEnabled(): boolean {
return process.env.PLUGIN_E2E_RUNTIME_SELECTORS === 'true';
}

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,
selectorsUrl: string | undefined,
grafanaVersion: string
): Promise<E2ESelectorGroups> {
// couldn't derive where the instance serves the file (older Grafana, or assets missing) -> bundled
if (!selectorsUrl) {
return bundledGroups(grafanaVersion);
}

let response;
try {
response = await request.get(selectorsUrl, { maxRedirects: 0 });
} catch (error) {
console.warn(`@grafana/plugin-e2e: failed to fetch ${selectorsUrl}, 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: ${selectorsUrl} 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 ${selectorsUrl}, falling back to bundled selectors.`, error);
return bundledGroups(grafanaVersion);
}
}

export const selectors: SelectorFixture = async ({ grafanaVersion, bootData, request }, use) => {
// until the runtime path is rolled out, only fetch when explicitly enabled; otherwise use the
// selectors bundled with the installed release
if (!runtimeSelectorsEnabled()) {
await use(bundledGroups(grafanaVersion));
return;
}

// 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, bootData.selectorsUrl, grafanaVersion);
selectorsCache.set(grafanaVersion, groups);
}
await use(await groups);
};
6 changes: 6 additions & 0 deletions packages/plugin-e2e/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ declare global {
};
namespace: string;
};
// asset base for the frontend build. jsFiles paths are CDN-absolute in multi-tenant and
// origin-relative in single-binary, which is what lets us locate sibling build assets.
assets?: {
cdn?: string;
jsFiles?: Array<{ filePath?: string }>;
};
};
}
// eslint-disable-next-line @typescript-eslint/no-namespace
Expand Down
Loading
Loading