Dashboards: Enable scenes by default (#93818)
* Mark Scenes feature toggles as GA * Move old arch e2e to a new folder * Run E2E on scenes by default * Upgrade e2e-selectors to ensure the tests in Playwright works
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { e2e } from '../';
|
||||
|
||||
export interface BenchmarkArguments {
|
||||
name: string;
|
||||
dashboard: {
|
||||
folder: string;
|
||||
delayAfterOpening: number;
|
||||
skipPanelValidation: boolean;
|
||||
};
|
||||
repeat: number;
|
||||
duration: number;
|
||||
appStats?: {
|
||||
startCollecting?: (window: Window) => void;
|
||||
collect: (window: Window) => Record<string, unknown>;
|
||||
};
|
||||
skipScenario?: boolean;
|
||||
}
|
||||
|
||||
export const benchmark = ({
|
||||
name,
|
||||
skipScenario = false,
|
||||
repeat,
|
||||
duration,
|
||||
appStats,
|
||||
dashboard,
|
||||
}: BenchmarkArguments) => {
|
||||
if (skipScenario) {
|
||||
describe(name, () => {
|
||||
it.skip(name, () => {});
|
||||
});
|
||||
} else {
|
||||
describe(name, () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
e2e.flows.importDashboards(dashboard.folder, 1000, dashboard.skipPanelValidation);
|
||||
});
|
||||
|
||||
afterEach(() => e2e.flows.revertAllChanges());
|
||||
|
||||
Array(repeat)
|
||||
.fill(0)
|
||||
.map((_, i) => {
|
||||
const testName = `${name}-${i}`;
|
||||
return it(testName, () => {
|
||||
e2e.flows.openDashboard();
|
||||
|
||||
cy.wait(dashboard.delayAfterOpening);
|
||||
|
||||
if (appStats) {
|
||||
const startCollecting = appStats.startCollecting;
|
||||
if (startCollecting) {
|
||||
cy.window().then((win) => startCollecting(win));
|
||||
}
|
||||
|
||||
cy.startBenchmarking(testName);
|
||||
cy.wait(duration);
|
||||
|
||||
cy.window().then((win) => {
|
||||
cy.stopBenchmarking(testName, appStats.collect(win));
|
||||
});
|
||||
} else {
|
||||
cy.startBenchmarking(testName);
|
||||
cy.wait(duration);
|
||||
cy.stopBenchmarking(testName, {});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
copyToClipboard(): Chainable;
|
||||
copyFromClipboard(): Chainable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add('copyFromClipboard', () => {
|
||||
return cy.window().then((win) => {
|
||||
return cy.wrap(win.navigator.clipboard.readText());
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add(
|
||||
'copyToClipboard',
|
||||
{
|
||||
prevSubject: [],
|
||||
},
|
||||
(subject: string) => {
|
||||
return cy.window().then((win) => {
|
||||
return cy.wrap(win.navigator.clipboard.writeText(subject));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './localStorage';
|
||||
export * from './scenarioContext';
|
||||
export * from './selector';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,17 @@
|
||||
// @todo this actually returns type `Cypress.Chainable`
|
||||
const get = (key: string) =>
|
||||
cy.wrap({ getLocalStorage: () => localStorage.getItem(key) }, { log: false }).invoke('getLocalStorage');
|
||||
|
||||
export const getLocalStorage = (key: string) =>
|
||||
get(key).then((value) => {
|
||||
if (value === null) {
|
||||
return value;
|
||||
} else {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
});
|
||||
|
||||
export const requireLocalStorage = (key: string) =>
|
||||
get(key) // `getLocalStorage()` would turn 'null' into `null`
|
||||
.should('not.equal', null)
|
||||
.then((value) => JSON.parse(value));
|
||||
@@ -0,0 +1,7 @@
|
||||
import { e2e } from '../index';
|
||||
|
||||
export function waitForMonacoToLoad() {
|
||||
e2e.components.QueryField.container().children('[data-testid="Spinner"]').should('not.exist');
|
||||
cy.window().its('monaco').should('exist');
|
||||
cy.get('.monaco-editor textarea:first').should('exist');
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { DeleteDashboardConfig } from '../flows/deleteDashboard';
|
||||
import { DeleteDataSourceConfig } from '../flows/deleteDataSource';
|
||||
|
||||
export interface ScenarioContext {
|
||||
addedDashboards: DeleteDashboardConfig[];
|
||||
addedDataSources: DeleteDataSourceConfig[];
|
||||
lastAddedDashboard: string; // @todo rename to `lastAddedDashboardTitle`
|
||||
lastAddedDashboardUid: string;
|
||||
lastAddedDataSource: string; // @todo rename to `lastAddedDataSourceName`
|
||||
lastAddedDataSourceId: string;
|
||||
hasChangedUserPreferences: boolean;
|
||||
}
|
||||
|
||||
const scenarioContext: ScenarioContext = {
|
||||
addedDashboards: [],
|
||||
addedDataSources: [],
|
||||
hasChangedUserPreferences: false,
|
||||
get lastAddedDashboard() {
|
||||
return lastProperty(this.addedDashboards, 'title');
|
||||
},
|
||||
get lastAddedDashboardUid() {
|
||||
return lastProperty(this.addedDashboards, 'uid');
|
||||
},
|
||||
get lastAddedDataSource() {
|
||||
return lastProperty(this.addedDataSources, 'name');
|
||||
},
|
||||
get lastAddedDataSourceId() {
|
||||
return lastProperty(this.addedDataSources, 'id');
|
||||
},
|
||||
};
|
||||
|
||||
const lastProperty = <T extends DeleteDashboardConfig | DeleteDataSourceConfig, K extends keyof T>(
|
||||
items: T[],
|
||||
key: K
|
||||
) => items[items.length - 1]?.[key] ?? '';
|
||||
|
||||
export const getScenarioContext = (): Cypress.Chainable<ScenarioContext> =>
|
||||
cy
|
||||
.wrap(
|
||||
{
|
||||
getScenarioContext: (): ScenarioContext => ({ ...scenarioContext }),
|
||||
},
|
||||
{ log: false }
|
||||
)
|
||||
.invoke({ log: false }, 'getScenarioContext');
|
||||
|
||||
export const setScenarioContext = (newContext: Partial<ScenarioContext>): Cypress.Chainable<ScenarioContext> =>
|
||||
cy
|
||||
.wrap(
|
||||
{
|
||||
setScenarioContext: () => {
|
||||
Object.assign(scenarioContext, newContext);
|
||||
},
|
||||
},
|
||||
{ log: false }
|
||||
)
|
||||
.invoke({ log: false }, 'setScenarioContext');
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface SelectorApi {
|
||||
fromAriaLabel: (selector: string) => string;
|
||||
fromDataTestId: (selector: string) => string;
|
||||
fromSelector: (selector: string) => string;
|
||||
}
|
||||
|
||||
export const Selector: SelectorApi = {
|
||||
fromAriaLabel: (selector: string) => `[aria-label="${selector}"]`,
|
||||
fromDataTestId: (selector: string) => `[data-testid="${selector}"]`,
|
||||
fromSelector: (selector: string) => selector,
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { CssSelector, FunctionSelector, Selectors, StringSelector, UrlSelector } from '@grafana/e2e-selectors';
|
||||
|
||||
import { Selector } from './selector';
|
||||
import { fromBaseUrl } from './url';
|
||||
|
||||
export type VisitFunction = (args?: string, queryParams?: object) => Cypress.Chainable<Window>;
|
||||
export type E2EVisit = { visit: VisitFunction };
|
||||
export type E2EFunction = ((text?: string, options?: CypressOptions) => Cypress.Chainable<JQuery<HTMLElement>>) &
|
||||
E2EFunctionWithOnlyOptions;
|
||||
export type E2EFunctionWithOnlyOptions = (options?: CypressOptions) => Cypress.Chainable<JQuery<HTMLElement>>;
|
||||
|
||||
export type TypeSelectors<S> = S extends StringSelector
|
||||
? E2EFunctionWithOnlyOptions
|
||||
: S extends FunctionSelector
|
||||
? E2EFunction
|
||||
: S extends CssSelector
|
||||
? E2EFunction
|
||||
: S extends UrlSelector
|
||||
? E2EVisit & Omit<E2EFunctions<S>, 'url'>
|
||||
: S extends Record<string, string | FunctionSelector | CssSelector | UrlSelector | Selectors>
|
||||
? E2EFunctions<S>
|
||||
: S;
|
||||
|
||||
export type E2EFunctions<S extends Selectors> = {
|
||||
[P in keyof S]: TypeSelectors<S[P]>;
|
||||
};
|
||||
|
||||
export type E2EObjects<S extends Selectors> = E2EFunctions<S>;
|
||||
|
||||
export type E2EFactoryArgs<S extends Selectors> = { selectors: S };
|
||||
|
||||
export type CypressOptions = Partial<Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow>;
|
||||
|
||||
const processSelectors = <S extends Selectors>(e2eObjects: E2EFunctions<S>, selectors: S): E2EFunctions<S> => {
|
||||
const logOutput = (data: unknown) => cy.logToConsole('Retrieving Selector:', data);
|
||||
const keys = Object.keys(selectors);
|
||||
for (let index = 0; index < keys.length; index++) {
|
||||
const key = keys[index];
|
||||
const value = selectors[key];
|
||||
|
||||
if (key === 'url') {
|
||||
// @ts-ignore
|
||||
e2eObjects['visit'] = (args?: string, queryParams?: object) => {
|
||||
let parsedUrl = '';
|
||||
if (typeof value === 'string') {
|
||||
parsedUrl = fromBaseUrl(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'function' && args) {
|
||||
parsedUrl = fromBaseUrl(value(args));
|
||||
}
|
||||
|
||||
cy.logToConsole('Visiting', parsedUrl);
|
||||
if (queryParams) {
|
||||
return cy.visit({ url: parsedUrl, qs: queryParams });
|
||||
} else {
|
||||
return cy.visit(parsedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// @ts-ignore
|
||||
e2eObjects[key] = (options?: CypressOptions) => {
|
||||
logOutput(value);
|
||||
const selector = value.startsWith('data-testid')
|
||||
? Selector.fromDataTestId(value)
|
||||
: Selector.fromAriaLabel(value);
|
||||
|
||||
return cy.get(selector, options);
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'function') {
|
||||
// @ts-ignore
|
||||
e2eObjects[key] = function (textOrOptions?: string | CypressOptions, options?: CypressOptions) {
|
||||
// the input can only be ()
|
||||
if (arguments.length === 0) {
|
||||
const selector = value('');
|
||||
|
||||
logOutput(selector);
|
||||
return cy.get(selector);
|
||||
}
|
||||
|
||||
// the input can be (text) or (options)
|
||||
if (arguments.length === 1) {
|
||||
if (typeof textOrOptions === 'string') {
|
||||
const selectorText = value(textOrOptions);
|
||||
const selector = selectorText.startsWith('data-testid')
|
||||
? Selector.fromDataTestId(selectorText)
|
||||
: Selector.fromAriaLabel(selectorText);
|
||||
|
||||
logOutput(selector);
|
||||
return cy.get(selector);
|
||||
}
|
||||
const selector = value('');
|
||||
|
||||
logOutput(selector);
|
||||
return cy.get(selector, textOrOptions);
|
||||
}
|
||||
|
||||
// the input can only be (text, options)
|
||||
if (arguments.length === 2 && typeof textOrOptions === 'string') {
|
||||
const text = textOrOptions;
|
||||
const selectorText = value(text);
|
||||
const selector = text.startsWith('data-testid')
|
||||
? Selector.fromDataTestId(selectorText)
|
||||
: Selector.fromAriaLabel(selectorText);
|
||||
|
||||
logOutput(selector);
|
||||
return cy.get(selector, options);
|
||||
}
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
// @ts-ignore
|
||||
e2eObjects[key] = processSelectors({}, value);
|
||||
}
|
||||
}
|
||||
|
||||
return e2eObjects;
|
||||
};
|
||||
|
||||
export const e2eFactory = <S extends Selectors>({ selectors }: E2EFactoryArgs<S>): E2EObjects<S> => {
|
||||
const e2eObjects: E2EFunctions<S> = {} as E2EFunctions<S>;
|
||||
processSelectors(e2eObjects, selectors);
|
||||
|
||||
return { ...e2eObjects };
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
const getBaseUrl = () => Cypress.env('BASE_URL') || Cypress.config().baseUrl || 'http://localhost:3000';
|
||||
|
||||
export const fromBaseUrl = (url = '') => new URL(url, getBaseUrl()).href;
|
||||
|
||||
export const getDashboardUid = (url: string): string => {
|
||||
const matches = new URL(url).pathname.match(/\/d\/([^/]+)/);
|
||||
if (!matches) {
|
||||
throw new Error(`Couldn't parse uid from ${url}`);
|
||||
} else {
|
||||
return matches[1];
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user