Skip to content

Commit 6f0c516

Browse files
committed
feat(plugin-e2e): fetch e2e-selectors from Grafana at runtime behind a flag
Behind PLUGIN_E2E_RUNTIME_SELECTORS, the selectors fixture fetches /public/e2e-selectors.js from the Grafana under test and resolves from it, falling back to the bundled @grafana/e2e-selectors (quiet on 404, warns on other failures). Off by default; enabled in CI to trial it.
1 parent 01fabe9 commit 6f0c516

4 files changed

Lines changed: 238 additions & 7 deletions

File tree

.github/workflows/playwright-nightly.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ jobs:
3737
playwright-tests:
3838
needs: resolve-versions
3939
timeout-minutes: 60
40+
# trial: fetch e2e-selectors from the running Grafana at runtime instead of the bundled dep.
41+
# grafana-dev serves the file (exercises the fetch); latest stable 404s (exercises the fallback)
42+
env:
43+
PLUGIN_E2E_RUNTIME_SELECTORS: 'true'
4044
strategy:
4145
fail-fast: false
4246
matrix:

.github/workflows/playwright.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ jobs:
3333
playwright-tests:
3434
needs: resolve-versions
3535
timeout-minutes: 60
36+
# trial: fetch e2e-selectors from the running Grafana at runtime instead of the bundled dep
37+
env:
38+
PLUGIN_E2E_RUNTIME_SELECTORS: 'true'
3639
strategy:
3740
fail-fast: false
3841
matrix:
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
// identity resolveSelectors so we can assert which versioned object flowed through;
4+
// tagged fallback data so we can tell the bundled dep apart from the fetched bundle
5+
vi.mock('@grafana/e2e-selectors', () => ({
6+
resolveSelectors: vi.fn((versioned: unknown) => versioned),
7+
versionedComponents: { __source: 'dep-components' },
8+
versionedPages: { __source: 'dep-pages' },
9+
}));
10+
vi.mock('../selectors/versionedConstants', () => ({ versionedConstants: { __source: 'local-constants' } }));
11+
vi.mock('../selectors/versionedAPIs', () => ({ versionedAPIs: { __source: 'local-apis' } }));
12+
13+
import { selectors } from './selectors';
14+
15+
const VALID_BUNDLE = `
16+
exports.versionedComponents = { __source: 'fetched-components' };
17+
exports.versionedPages = { __source: 'fetched-pages' };
18+
`;
19+
20+
// a fake APIRequestContext.get() result
21+
function mockResponse({ status = 200, body = '' }: { status?: number; body?: string }) {
22+
return { status: () => status, ok: () => status >= 200 && status < 300, text: async () => body };
23+
}
24+
25+
function mockRequest(get: ReturnType<typeof vi.fn>) {
26+
// only `get` is exercised by the fixture
27+
return { get } as never;
28+
}
29+
30+
// drives the fixture and returns whatever it passes to `use`
31+
async function runFixture(args: { grafanaVersion: string; request: never }) {
32+
let captured: unknown;
33+
await (selectors as unknown as (args: unknown, use: (value: unknown) => Promise<void>) => Promise<void>)(
34+
args,
35+
async (value: unknown) => {
36+
captured = value;
37+
}
38+
);
39+
return captured as Record<string, unknown>;
40+
}
41+
42+
describe('selectors fixture', () => {
43+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
44+
45+
beforeEach(() => {
46+
warnSpy.mockClear();
47+
});
48+
49+
afterEach(() => {
50+
delete process.env.PLUGIN_E2E_RUNTIME_SELECTORS;
51+
});
52+
53+
it('flag off: uses the bundled dependency and never fetches', async () => {
54+
const get = vi.fn();
55+
const result = await runFixture({ grafanaVersion: '11.0.0-off', request: mockRequest(get) });
56+
57+
expect(get).not.toHaveBeenCalled();
58+
expect(result.components).toEqual({ __source: 'dep-components' });
59+
expect(result.constants).toEqual({ __source: 'local-constants' });
60+
});
61+
62+
it('flag on + 200: uses fetched components/pages and local constants/apis', async () => {
63+
process.env.PLUGIN_E2E_RUNTIME_SELECTORS = 'true';
64+
const get = vi.fn().mockResolvedValue(mockResponse({ status: 200, body: VALID_BUNDLE }));
65+
66+
const result = await runFixture({ grafanaVersion: '11.0.0-200', request: mockRequest(get) });
67+
68+
expect(get).toHaveBeenCalledWith('/public/e2e-selectors.js');
69+
expect(result.components).toEqual({ __source: 'fetched-components' });
70+
expect(result.pages).toEqual({ __source: 'fetched-pages' });
71+
expect(result.constants).toEqual({ __source: 'local-constants' });
72+
expect(result.apis).toEqual({ __source: 'local-apis' });
73+
expect(warnSpy).not.toHaveBeenCalled();
74+
});
75+
76+
it('flag on + 404: falls back to the dependency quietly', async () => {
77+
process.env.PLUGIN_E2E_RUNTIME_SELECTORS = 'true';
78+
const get = vi.fn().mockResolvedValue(mockResponse({ status: 404 }));
79+
80+
const result = await runFixture({ grafanaVersion: '11.0.0-404', request: mockRequest(get) });
81+
82+
expect(result.components).toEqual({ __source: 'dep-components' });
83+
expect(warnSpy).not.toHaveBeenCalled();
84+
});
85+
86+
it('flag on + 5xx: falls back to the dependency with a warning', async () => {
87+
process.env.PLUGIN_E2E_RUNTIME_SELECTORS = 'true';
88+
const get = vi.fn().mockResolvedValue(mockResponse({ status: 503 }));
89+
90+
const result = await runFixture({ grafanaVersion: '11.0.0-503', request: mockRequest(get) });
91+
92+
expect(result.components).toEqual({ __source: 'dep-components' });
93+
expect(warnSpy).toHaveBeenCalled();
94+
});
95+
96+
it('flag on + network error: falls back to the dependency with a warning', async () => {
97+
process.env.PLUGIN_E2E_RUNTIME_SELECTORS = 'true';
98+
const get = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
99+
100+
const result = await runFixture({ grafanaVersion: '11.0.0-net', request: mockRequest(get) });
101+
102+
expect(result.components).toEqual({ __source: 'dep-components' });
103+
expect(warnSpy).toHaveBeenCalled();
104+
});
105+
106+
it('flag on + malformed bundle (missing exports): falls back to the dependency with a warning', async () => {
107+
process.env.PLUGIN_E2E_RUNTIME_SELECTORS = 'true';
108+
const get = vi.fn().mockResolvedValue(mockResponse({ status: 200, body: 'exports.nope = 1;' }));
109+
110+
const result = await runFixture({ grafanaVersion: '11.0.0-bad', request: mockRequest(get) });
111+
112+
expect(result.components).toEqual({ __source: 'dep-components' });
113+
expect(warnSpy).toHaveBeenCalled();
114+
});
115+
116+
it('flag on: concurrent fixtures for the same version share a single fetch', async () => {
117+
process.env.PLUGIN_E2E_RUNTIME_SELECTORS = 'true';
118+
const get = vi.fn().mockResolvedValue(mockResponse({ status: 200, body: VALID_BUNDLE }));
119+
const request = mockRequest(get);
120+
121+
const [a, b] = await Promise.all([
122+
runFixture({ grafanaVersion: '11.0.0-cache', request }),
123+
runFixture({ grafanaVersion: '11.0.0-cache', request }),
124+
]);
125+
126+
expect(get).toHaveBeenCalledTimes(1);
127+
expect(a.components).toEqual({ __source: 'fetched-components' });
128+
expect(b.components).toEqual({ __source: 'fetched-components' });
129+
});
130+
});
Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,110 @@
1-
import { TestFixture } from '@playwright/test';
1+
import { APIRequestContext, TestFixture } from '@playwright/test';
2+
import {
3+
resolveSelectors,
4+
versionedComponents as bundledVersionedComponents,
5+
versionedPages as bundledVersionedPages,
6+
} from '@grafana/e2e-selectors';
27
import { E2ESelectorGroups, PlaywrightArgs } from '../types';
3-
import { resolveSelectors, versionedComponents, versionedPages } from '@grafana/e2e-selectors';
48
import { versionedConstants } from '../selectors/versionedConstants';
59
import { versionedAPIs } from '../selectors/versionedAPIs';
610

711
type SelectorFixture = TestFixture<E2ESelectorGroups, PlaywrightArgs>;
812

9-
export const selectors: SelectorFixture = async ({ grafanaVersion }, use) => {
10-
await use({
11-
components: resolveSelectors(versionedComponents, grafanaVersion),
12-
pages: resolveSelectors(versionedPages, grafanaVersion),
13+
// served by grafana/grafana from the @grafana/e2e-selectors build (see PLUGIN_E2E_RUNTIME_SELECTORS)
14+
const SELECTORS_URL = '/public/e2e-selectors.js';
15+
16+
// only the components/pages data comes from Grafana; constants/apis are plugin-e2e's own
17+
type RuntimeSelectors = {
18+
versionedComponents: typeof bundledVersionedComponents;
19+
versionedPages: typeof bundledVersionedPages;
20+
};
21+
22+
// per-worker cache keyed by grafanaVersion so concurrent fixtures share one in-flight fetch
23+
const selectorsCache = new Map<string, Promise<E2ESelectorGroups>>();
24+
25+
// evaluate the fetched CJS bundle with a require shim that allows only 'semver'
26+
function evaluateBundle(src: string): RuntimeSelectors {
27+
const shimRequire = (id: string) => {
28+
if (id !== 'semver') {
29+
throw new Error(`@grafana/plugin-e2e: disallowed require('${id}') in e2e-selectors bundle`);
30+
}
31+
return require('semver');
32+
};
33+
const module: { exports: Partial<RuntimeSelectors> } = { exports: {} };
34+
new Function('require', 'module', 'exports', src)(shimRequire, module, module.exports);
35+
36+
const { versionedComponents, versionedPages } = module.exports;
37+
if (!versionedComponents || !versionedPages) {
38+
throw new Error('@grafana/plugin-e2e: e2e-selectors bundle is missing expected exports');
39+
}
40+
return { versionedComponents, versionedPages };
41+
}
42+
43+
function buildGroups(runtime: RuntimeSelectors, grafanaVersion: string): E2ESelectorGroups {
44+
return {
45+
components: resolveSelectors(runtime.versionedComponents, grafanaVersion),
46+
pages: resolveSelectors(runtime.versionedPages, grafanaVersion),
1347
constants: resolveSelectors(versionedConstants, grafanaVersion),
1448
apis: resolveSelectors(versionedAPIs, grafanaVersion),
15-
});
49+
};
50+
}
51+
52+
// fall back to the selectors bundled with the installed @grafana/plugin-e2e release
53+
function bundledGroups(grafanaVersion: string): E2ESelectorGroups {
54+
return buildGroups(
55+
{ versionedComponents: bundledVersionedComponents, versionedPages: bundledVersionedPages },
56+
grafanaVersion
57+
);
58+
}
59+
60+
async function fetchRuntimeSelectors(request: APIRequestContext, grafanaVersion: string): Promise<E2ESelectorGroups> {
61+
let response;
62+
try {
63+
response = await request.get(SELECTORS_URL);
64+
} catch (error) {
65+
console.warn(
66+
`@grafana/plugin-e2e: failed to fetch ${SELECTORS_URL}, falling back to bundled @grafana/e2e-selectors.`,
67+
error
68+
);
69+
return bundledGroups(grafanaVersion);
70+
}
71+
72+
// 404 -> Grafana predates the feature; expected on older images, fall back quietly
73+
if (response.status() === 404) {
74+
return bundledGroups(grafanaVersion);
75+
}
76+
77+
// any other non-OK status (5xx etc.) -> loud fallback
78+
if (!response.ok()) {
79+
console.warn(
80+
`@grafana/plugin-e2e: ${SELECTORS_URL} returned ${response.status()}, falling back to bundled @grafana/e2e-selectors.`
81+
);
82+
return bundledGroups(grafanaVersion);
83+
}
84+
85+
try {
86+
const src = await response.text();
87+
return buildGroups(evaluateBundle(src), grafanaVersion);
88+
} catch (error) {
89+
console.warn(
90+
`@grafana/plugin-e2e: failed to evaluate ${SELECTORS_URL}, falling back to bundled @grafana/e2e-selectors.`,
91+
error
92+
);
93+
return bundledGroups(grafanaVersion);
94+
}
95+
}
96+
97+
export const selectors: SelectorFixture = async ({ grafanaVersion, request }, use) => {
98+
// opt-in during the trial period; when off, behave exactly as before (bundled dependency)
99+
if (process.env.PLUGIN_E2E_RUNTIME_SELECTORS !== 'true') {
100+
await use(bundledGroups(grafanaVersion));
101+
return;
102+
}
103+
104+
let groups = selectorsCache.get(grafanaVersion);
105+
if (!groups) {
106+
groups = fetchRuntimeSelectors(request, grafanaVersion);
107+
selectorsCache.set(grafanaVersion, groups);
108+
}
109+
await use(await groups);
16110
};

0 commit comments

Comments
 (0)