mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
@grafana/e2e: API improvements (#23079)
* Minor changes * Fixtures path is now relative to the project directory * URL support module now has individual exports * Scenario context timing issues resolved ... caused by being ran synchronously, instead of as part of Cypress' asynchronous queue. * Scenario context API now supports multiple keys per function call * addDataSource flow accepts a config argument … and optionally checks datasource health status * Added readProvisions command * Added addPanel flow
This commit is contained in:
parent
13ab84f201
commit
a4308fffe7
@ -190,13 +190,17 @@ const assertAdding3dependantQueryVariablesScenario = (queryVariables: QueryVaria
|
||||
for (let queryVariableIndex = 0; queryVariableIndex < queryVariables.length; queryVariableIndex++) {
|
||||
const { name, label, query, options, selectedOption } = queryVariables[queryVariableIndex];
|
||||
const asserts = queryVariables.slice(0, queryVariableIndex + 1);
|
||||
createQueryVariable({
|
||||
dataSourceName: e2e.context().get('lastAddedDataSource'),
|
||||
name,
|
||||
label,
|
||||
query,
|
||||
options,
|
||||
selectedOption,
|
||||
// @todo remove `@ts-ignore` when possible
|
||||
// @ts-ignore
|
||||
e2e.getScenarioContext().then(({ lastAddedDataSource }) => {
|
||||
createQueryVariable({
|
||||
dataSourceName: lastAddedDataSource,
|
||||
name,
|
||||
label,
|
||||
query,
|
||||
options,
|
||||
selectedOption,
|
||||
});
|
||||
});
|
||||
|
||||
assertVariableTable(asserts);
|
||||
@ -565,7 +569,11 @@ e2e.scenario({
|
||||
addScenarioDashBoard: true,
|
||||
skipScenario: false,
|
||||
scenario: () => {
|
||||
e2e.flows.openDashboard(e2e.context().get('lastAddedDashboardUid'));
|
||||
// @todo remove `@ts-ignore` when possible
|
||||
// @ts-ignore
|
||||
e2e.getScenarioContext().then(({ lastAddedDashboardUid }) => {
|
||||
e2e.flows.openDashboard(lastAddedDashboardUid);
|
||||
});
|
||||
e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').click();
|
||||
e2e.pages.Dashboard.Settings.General.sectionItems('Variables').click();
|
||||
e2e.pages.Dashboard.Settings.Variables.List.addVariableCTA().click();
|
||||
|
@ -7,7 +7,11 @@ e2e.scenario({
|
||||
addScenarioDashBoard: true,
|
||||
skipScenario: false,
|
||||
scenario: () => {
|
||||
e2e.flows.openDashboard(e2e.context().get('lastAddedDashboardUid'));
|
||||
// @todo remove `@ts-ignore` when possible
|
||||
// @ts-ignore
|
||||
e2e.getScenarioContext().then(({ lastAddedDashboardUid }) => {
|
||||
e2e.flows.openDashboard(lastAddedDashboardUid);
|
||||
});
|
||||
e2e.pages.Dashboard.Toolbar.toolbarItems('Add panel').click();
|
||||
e2e.pages.AddDashboard.ctaButtons('Add Query').click();
|
||||
|
||||
|
@ -12,6 +12,7 @@ module.exports = async baseConfig => {
|
||||
|
||||
if (CWD) {
|
||||
const projectConfig = {
|
||||
fixturesFolder: `${CWD}/cypress/fixtures`,
|
||||
integrationFolder: `${CWD}/cypress/integration`,
|
||||
screenshotsFolder: `${CWD}/cypress/screenshots`,
|
||||
videosFolder: `${CWD}/cypress/videos`,
|
||||
|
@ -1,5 +1,6 @@
|
||||
const compareSnapshotsPlugin = require('./compareSnapshots');
|
||||
const extendConfig = require('./extendConfig');
|
||||
const readProvisions = require('./readProvisions');
|
||||
const typescriptPreprocessor = require('./typescriptPreprocessor');
|
||||
|
||||
module.exports = (on, config) => {
|
||||
@ -10,7 +11,7 @@ module.exports = (on, config) => {
|
||||
// failed: require('cypress-failed-log/src/failed')(),
|
||||
// });
|
||||
on('file:preprocessor', typescriptPreprocessor);
|
||||
on('task', { compareSnapshotsPlugin });
|
||||
on('task', { compareSnapshotsPlugin, readProvisions });
|
||||
on('task', {
|
||||
log({ message, optional }) {
|
||||
optional ? console.log(message, optional) : console.log(message);
|
||||
|
14
packages/grafana-e2e/cypress/plugins/readProvisions.js
Normal file
14
packages/grafana-e2e/cypress/plugins/readProvisions.js
Normal file
@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
const { parse: parseYml } = require('yaml');
|
||||
const {
|
||||
promises: { readFile },
|
||||
} = require('fs');
|
||||
const { resolve: resolvePath } = require('path');
|
||||
|
||||
const readProvision = filePath => readFile(filePath, 'utf8').then(contents => parseYml(contents));
|
||||
|
||||
const readProvisions = filePaths => Promise.all(filePaths.map(readProvision));
|
||||
|
||||
// Paths are relative to <project-root>/provisioning
|
||||
module.exports = ({ CWD, filePaths }) =>
|
||||
readProvisions(filePaths.map(filePath => resolvePath(CWD, 'provisioning', filePath)));
|
@ -25,3 +25,10 @@ Cypress.Commands.add('compareSnapshot', (args: CompareSnapshotArgs) => {
|
||||
Cypress.Commands.add('logToConsole', (message: string, optional?: any) => {
|
||||
cy.task('log', { message, optional });
|
||||
});
|
||||
|
||||
Cypress.Commands.add('readProvisions', (filePaths: string[]) => {
|
||||
cy.task('readProvisions', {
|
||||
CWD: Cypress.env('CWD'),
|
||||
filePaths,
|
||||
});
|
||||
});
|
||||
|
@ -4,5 +4,6 @@ declare namespace Cypress {
|
||||
interface Chainable {
|
||||
compareSnapshot(args: CompareSnapshotArgs): void;
|
||||
logToConsole(message: string, optional?: any): void;
|
||||
readProvisions(filePaths: string[]): Chainable;
|
||||
}
|
||||
}
|
||||
|
@ -55,6 +55,7 @@
|
||||
"cypress": "3.7.0",
|
||||
"execa": "4.0.0",
|
||||
"ts-loader": "6.2.1",
|
||||
"typescript": "3.7.2"
|
||||
"typescript": "3.7.2",
|
||||
"yaml": "^1.8.3"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { e2e } from '../index';
|
||||
import { Url } from '../support/url';
|
||||
import { getDashboardUid } from '../support/url';
|
||||
|
||||
export const addDashboard = () => {
|
||||
e2e().logToConsole('Adding dashboard');
|
||||
@ -11,7 +11,9 @@ export const addDashboard = () => {
|
||||
e2e()
|
||||
.url()
|
||||
.then((url: string) => {
|
||||
e2e.context().set('lastAddedDashboard', dashboardTitle);
|
||||
e2e.context().set('lastAddedDashboardUid', Url.getDashboardUid(url));
|
||||
e2e.setScenarioContext({
|
||||
lastAddedDashboard: dashboardTitle,
|
||||
lastAddedDashboardUid: getDashboardUid(url),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -1,22 +1,64 @@
|
||||
import { e2e } from '../index';
|
||||
import { fromBaseUrl, getDataSourceId } from '../support/url';
|
||||
import { setScenarioContext } from '../support/scenarioContext';
|
||||
|
||||
export const addDataSource = (pluginName?: string): string => {
|
||||
pluginName = pluginName || 'TestData DB';
|
||||
e2e().logToConsole('Adding data source with pluginName:', pluginName);
|
||||
export interface AddDataSourceConfig {
|
||||
checkHealth: boolean;
|
||||
expectedAlertMessage: string;
|
||||
form: Function;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ADD_DATA_SOURCE_CONFIG: AddDataSourceConfig = {
|
||||
checkHealth: false,
|
||||
expectedAlertMessage: 'Data source is working',
|
||||
form: () => {},
|
||||
name: 'TestData DB',
|
||||
};
|
||||
|
||||
export const addDataSource = (config?: Partial<AddDataSourceConfig>): string => {
|
||||
const { checkHealth, expectedAlertMessage, form, name } = { ...DEFAULT_ADD_DATA_SOURCE_CONFIG, ...config };
|
||||
|
||||
e2e().logToConsole('Adding data source with name:', name);
|
||||
e2e.pages.AddDataSource.visit();
|
||||
e2e.pages.AddDataSource.dataSourcePlugins(pluginName)
|
||||
e2e.pages.AddDataSource.dataSourcePlugins(name)
|
||||
.scrollIntoView()
|
||||
.should('be.visible') // prevents flakiness
|
||||
.click();
|
||||
|
||||
const dataSourceName = `e2e-${new Date().getTime()}`;
|
||||
const dataSourceName = `e2e-${Date.now()}`;
|
||||
e2e.pages.DataSource.name().clear();
|
||||
e2e.pages.DataSource.name().type(dataSourceName);
|
||||
form();
|
||||
e2e.pages.DataSource.saveAndTest().click();
|
||||
e2e.pages.DataSource.alert().should('exist');
|
||||
e2e.pages.DataSource.alertMessage().should('contain.text', 'Data source is working');
|
||||
e2e.pages.DataSource.alertMessage().should('contain.text', expectedAlertMessage);
|
||||
e2e().logToConsole('Added data source with name:', dataSourceName);
|
||||
e2e.context().set('lastAddedDataSource', dataSourceName);
|
||||
|
||||
if (checkHealth) {
|
||||
e2e()
|
||||
.url()
|
||||
.then((url: string) => {
|
||||
const dataSourceId = getDataSourceId(url);
|
||||
|
||||
setScenarioContext({
|
||||
lastAddedDataSource: dataSourceName,
|
||||
lastAddedDataSourceId: dataSourceId,
|
||||
});
|
||||
|
||||
const healthUrl = fromBaseUrl(`/api/datasources/${dataSourceId}/health`);
|
||||
e2e().logToConsole(`Fetching ${healthUrl}`);
|
||||
e2e()
|
||||
.request(healthUrl)
|
||||
.its('body')
|
||||
.should('have.property', 'status')
|
||||
.and('eq', 'OK');
|
||||
});
|
||||
} else {
|
||||
setScenarioContext({
|
||||
lastAddedDataSource: dataSourceName,
|
||||
});
|
||||
}
|
||||
|
||||
return dataSourceName;
|
||||
};
|
||||
|
30
packages/grafana-e2e/src/flows/addPanel.ts
Normal file
30
packages/grafana-e2e/src/flows/addPanel.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { e2e } from '../index';
|
||||
import { getScenarioContext } from '../support/scenarioContext';
|
||||
|
||||
export interface AddPanelConfig {
|
||||
dataSourceName: string;
|
||||
queriesForm: Function;
|
||||
}
|
||||
|
||||
const DEFAULT_ADD_PANEL_CONFIG: AddPanelConfig = {
|
||||
dataSourceName: 'TestData DB',
|
||||
queriesForm: () => {},
|
||||
};
|
||||
|
||||
export const addPanel = (config?: Partial<AddPanelConfig>) => {
|
||||
const { dataSourceName, queriesForm } = { ...DEFAULT_ADD_PANEL_CONFIG, ...config };
|
||||
|
||||
// @todo remove `@ts-ignore` when possible
|
||||
// @ts-ignore
|
||||
getScenarioContext().then(({ lastAddedDashboardUid }) => {
|
||||
e2e.flows.openDashboard(lastAddedDashboardUid);
|
||||
e2e.pages.Dashboard.Toolbar.toolbarItems('Add panel').click();
|
||||
e2e.pages.AddDashboard.ctaButtons('Add Query').click();
|
||||
e2e()
|
||||
.get('.ds-picker')
|
||||
.click()
|
||||
.contains(dataSourceName)
|
||||
.click();
|
||||
queriesForm();
|
||||
});
|
||||
};
|
@ -1,9 +1,9 @@
|
||||
import { Url } from '../support/url';
|
||||
import { e2e } from '../index';
|
||||
import { fromBaseUrl } from '../support/url';
|
||||
|
||||
export const deleteDashboard = (dashBoardUid: string) => {
|
||||
e2e().logToConsole('Deleting dashboard with uid:', dashBoardUid);
|
||||
e2e().request('DELETE', Url.fromBaseUrl(`/api/dashboards/uid/${dashBoardUid}`));
|
||||
e2e().request('DELETE', fromBaseUrl(`/api/dashboards/uid/${dashBoardUid}`));
|
||||
|
||||
/* https://github.com/cypress-io/cypress/issues/2831
|
||||
Flows.openDashboard(dashboardName);
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Url } from '../support/url';
|
||||
import { e2e } from '../index';
|
||||
import { fromBaseUrl } from '../support/url';
|
||||
|
||||
export const deleteDataSource = (dataSourceName: string) => {
|
||||
e2e().logToConsole('Deleting data source with name:', dataSourceName);
|
||||
e2e().request('DELETE', Url.fromBaseUrl(`/api/datasources/name/${dataSourceName}`));
|
||||
e2e().request('DELETE', fromBaseUrl(`/api/datasources/name/${dataSourceName}`));
|
||||
|
||||
/* https://github.com/cypress-io/cypress/issues/2831
|
||||
Pages.DataSources.visit();
|
||||
|
@ -1,21 +1,23 @@
|
||||
import { login } from './login';
|
||||
import { addDataSource } from './addDataSource';
|
||||
import { deleteDataSource } from './deleteDataSource';
|
||||
import { addDashboard } from './addDashboard';
|
||||
import { addDataSource } from './addDataSource';
|
||||
import { addPanel } from './addPanel';
|
||||
import { assertSuccessNotification } from './assertSuccessNotification';
|
||||
import { deleteDashboard } from './deleteDashboard';
|
||||
import { deleteDataSource } from './deleteDataSource';
|
||||
import { login } from './login';
|
||||
import { openDashboard } from './openDashboard';
|
||||
import { saveNewDashboard } from './saveNewDashboard';
|
||||
import { saveDashboard } from './saveDashboard';
|
||||
import { saveNewDashboard } from './saveNewDashboard';
|
||||
|
||||
export const Flows = {
|
||||
login,
|
||||
addDataSource,
|
||||
deleteDataSource,
|
||||
addDashboard,
|
||||
addDataSource,
|
||||
addPanel,
|
||||
assertSuccessNotification,
|
||||
deleteDashboard,
|
||||
deleteDataSource,
|
||||
login,
|
||||
openDashboard,
|
||||
saveNewDashboard,
|
||||
saveDashboard,
|
||||
saveNewDashboard,
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import { e2e } from '../index';
|
||||
export const saveNewDashboard = () => {
|
||||
e2e.pages.Dashboard.Toolbar.toolbarItems('Save dashboard').click();
|
||||
|
||||
const dashboardTitle = `e2e-${new Date().getTime()}`;
|
||||
const dashboardTitle = `e2e-${Date.now()}`;
|
||||
e2e.pages.SaveDashboardAsModal.newName().clear();
|
||||
e2e.pages.SaveDashboardAsModal.newName().type(dashboardTitle);
|
||||
e2e.pages.SaveDashboardAsModal.save().click();
|
||||
|
@ -6,7 +6,7 @@
|
||||
import { e2eScenario, ScenarioArguments } from './support/scenario';
|
||||
import { Pages } from './pages';
|
||||
import { Flows } from './flows';
|
||||
import { scenarioContext } from './support/scenarioContext';
|
||||
import { getScenarioContext, setScenarioContext } from './support/scenarioContext';
|
||||
|
||||
export type SelectorFunction = (text?: string) => Cypress.Chainable<JQuery<HTMLElement>>;
|
||||
export type SelectorObject<S> = {
|
||||
@ -20,9 +20,10 @@ const e2eObject = {
|
||||
blobToBase64String: (blob: any) => Cypress.Blob.blobToBase64String(blob),
|
||||
imgSrcToBlob: (url: string) => Cypress.Blob.imgSrcToBlob(url),
|
||||
scenario: (args: ScenarioArguments) => e2eScenario(args),
|
||||
context: scenarioContext,
|
||||
pages: Pages,
|
||||
flows: Flows,
|
||||
getScenarioContext,
|
||||
setScenarioContext,
|
||||
};
|
||||
|
||||
export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject);
|
||||
|
@ -3,7 +3,7 @@ import { e2e } from '../index';
|
||||
export interface ScenarioArguments {
|
||||
describeName: string;
|
||||
itName: string;
|
||||
scenario: () => void;
|
||||
scenario: Function;
|
||||
skipScenario?: boolean;
|
||||
addScenarioDataSource?: boolean;
|
||||
addScenarioDashBoard?: boolean;
|
||||
@ -19,34 +19,33 @@ export const e2eScenario = ({
|
||||
}: ScenarioArguments) => {
|
||||
describe(describeName, () => {
|
||||
if (skipScenario) {
|
||||
it.skip(itName, () => {
|
||||
// @ts-ignore yarn start in root throws error otherwise
|
||||
expect(false).equals(true);
|
||||
it.skip(itName, () => scenario());
|
||||
} else {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login('admin', 'admin');
|
||||
if (addScenarioDataSource) {
|
||||
e2e.flows.addDataSource();
|
||||
}
|
||||
if (addScenarioDashBoard) {
|
||||
e2e.flows.addDashboard();
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
||||
afterEach(() => {
|
||||
// @todo remove `@ts-ignore` when possible
|
||||
// @ts-ignore
|
||||
e2e.getScenarioContext().then(({ lastAddedDashboardUid, lastAddedDataSource }) => {
|
||||
if (lastAddedDataSource) {
|
||||
e2e.flows.deleteDataSource(lastAddedDataSource);
|
||||
}
|
||||
|
||||
if (lastAddedDashboardUid) {
|
||||
e2e.flows.deleteDashboard(lastAddedDashboardUid);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it(itName, () => scenario());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
e2e.flows.login('admin', 'admin');
|
||||
if (addScenarioDataSource) {
|
||||
e2e.flows.addDataSource('TestData DB');
|
||||
}
|
||||
if (addScenarioDashBoard) {
|
||||
e2e.flows.addDashboard();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (e2e.context().get('lastAddedDataSource')) {
|
||||
e2e.flows.deleteDataSource(e2e.context().get('lastAddedDataSource'));
|
||||
}
|
||||
if (e2e.context().get('lastAddedDashboardUid')) {
|
||||
e2e.flows.deleteDashboard(e2e.context().get('lastAddedDashboardUid'));
|
||||
}
|
||||
});
|
||||
|
||||
it(itName, () => {
|
||||
scenario();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -1,29 +1,36 @@
|
||||
import { e2e } from '../index';
|
||||
|
||||
export interface ScenarioContext {
|
||||
lastAddedDataSource: string;
|
||||
lastAddedDashboard: string;
|
||||
lastAddedDashboardUid: string;
|
||||
lastAddedDataSource: string;
|
||||
lastAddedDataSourceId: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const scenarioContexts: ScenarioContext = {
|
||||
lastAddedDataSource: '',
|
||||
const scenarioContext: ScenarioContext = {
|
||||
lastAddedDashboard: '',
|
||||
lastAddedDashboardUid: '',
|
||||
lastAddedDataSource: '',
|
||||
lastAddedDataSourceId: '',
|
||||
};
|
||||
|
||||
export interface ScenarioContextApi {
|
||||
get: <T>(name: string | keyof ScenarioContext) => T;
|
||||
set: <T>(name: string | keyof ScenarioContext, value: T) => void;
|
||||
}
|
||||
// @todo this actually returns type `Cypress.Chainable`
|
||||
export const getScenarioContext = (): any =>
|
||||
e2e()
|
||||
.wrap({
|
||||
getScenarioContext: () => ({ ...scenarioContext } as ScenarioContext),
|
||||
})
|
||||
.invoke('getScenarioContext');
|
||||
|
||||
export const scenarioContext = (): ScenarioContextApi => {
|
||||
const get = <T>(name: string | keyof ScenarioContext): T => scenarioContexts[name] as T;
|
||||
const set = <T>(name: string | keyof ScenarioContext, value: T): void => {
|
||||
scenarioContexts[name] = value;
|
||||
};
|
||||
|
||||
return {
|
||||
get,
|
||||
set,
|
||||
};
|
||||
};
|
||||
// @todo this actually returns type `Cypress.Chainable`
|
||||
export const setScenarioContext = (newContext: Partial<ScenarioContext>): any =>
|
||||
e2e()
|
||||
.wrap({
|
||||
setScenarioContext: () => {
|
||||
Object.entries(newContext).forEach(([key, value]) => {
|
||||
scenarioContext[key] = value;
|
||||
});
|
||||
},
|
||||
})
|
||||
.invoke('setScenarioContext');
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Selector } from './selector';
|
||||
import { Url } from './url';
|
||||
import { fromBaseUrl } from './url';
|
||||
import { e2e } from '../index';
|
||||
import { SelectorFunction, SelectorObject } from '../noTypeCheck';
|
||||
|
||||
@ -19,11 +19,11 @@ export const pageFactory = <S extends Selectors>({ url, selectors }: PageFactory
|
||||
|
||||
let parsedUrl = '';
|
||||
if (typeof url === 'string') {
|
||||
parsedUrl = Url.fromBaseUrl(url);
|
||||
parsedUrl = fromBaseUrl(url);
|
||||
}
|
||||
|
||||
if (typeof url === 'function' && args) {
|
||||
parsedUrl = Url.fromBaseUrl(url(args));
|
||||
parsedUrl = fromBaseUrl(url(args));
|
||||
}
|
||||
|
||||
e2e().logToConsole('Visiting', parsedUrl);
|
||||
|
@ -1,25 +1,26 @@
|
||||
import { e2e } from '../index';
|
||||
|
||||
export interface UrlApi {
|
||||
fromBaseUrl: (url: string | undefined) => string;
|
||||
getDashboardUid: (url: string) => string;
|
||||
}
|
||||
|
||||
const uidRegex = '\\/d\\/(.*)\\/';
|
||||
const getBaseUrl = () => e2e.env('BASE_URL') || e2e.config().baseUrl || 'http://localhost:3000';
|
||||
|
||||
export const Url: UrlApi = {
|
||||
fromBaseUrl: (url: string | undefined) => {
|
||||
url = url || '';
|
||||
const strippedUrl = url.replace('^/', '');
|
||||
return `${getBaseUrl()}${strippedUrl}`;
|
||||
},
|
||||
getDashboardUid: (url: string) => {
|
||||
const matches = url.match(uidRegex);
|
||||
if (!matches) {
|
||||
throw new Error(`Couldn't parse uid from ${url}`);
|
||||
}
|
||||
|
||||
return matches[1];
|
||||
},
|
||||
export const fromBaseUrl = (url = ''): string => {
|
||||
const strippedUrl = url.replace('^/', '');
|
||||
return `${getBaseUrl()}${strippedUrl}`;
|
||||
};
|
||||
|
||||
export const getDashboardUid = (url: string): string => {
|
||||
const matches = url.match(/\/d\/(.*)\//);
|
||||
if (!matches) {
|
||||
throw new Error(`Couldn't parse uid from ${url}`);
|
||||
} else {
|
||||
return matches[1];
|
||||
}
|
||||
};
|
||||
|
||||
export const getDataSourceId = (url: string): string => {
|
||||
const matches = url.match(/\/edit\/(.*)\//);
|
||||
if (!matches) {
|
||||
throw new Error(`Couldn't parse id from ${url}`);
|
||||
} else {
|
||||
return matches[1];
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user