Explore: Integration test for running a query and saving it in query history (#45728)

* Create basic query history test

* Clean up

* Clean up

* Use run button selector

* Mock AutoSizer instead of monkey-patching

* Reset local storage after each test

* Add accessible name to Run Query button and use it in the test

* Update public/app/features/explore/spec/helper/interactions.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Rename query to urlParams

* Fix linting errors

* Remove unused import

Co-authored-by: Giordano Ricci <me@giordanoricci.com>
This commit is contained in:
Piotr Jamróz 2022-02-23 19:05:38 +01:00 committed by GitHub
parent f75bea481d
commit 5715be4afa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 294 additions and 190 deletions

View File

@ -69,6 +69,7 @@ export class RefreshPicker extends PureComponent<Props> {
return (
<ButtonGroup className="refresh-picker">
<ToolbarButton
aria-label={text}
tooltip={tooltip}
onClick={onRefresh}
variant={variant}

View File

@ -16,6 +16,7 @@ export function RunButton(props: Props) {
const { isSmall, loading, onRun, onChangeRefreshInterval, refreshInterval, showDropdown, isLive } = props;
const intervals = getTimeSrv().getValidIntervals(defaultIntervals);
let text: string | undefined = loading ? 'Cancel' : 'Run query';
let tooltip = '';
let width = '108px';
if (isLive) {
@ -23,6 +24,7 @@ export function RunButton(props: Props) {
}
if (isSmall) {
tooltip = text;
text = undefined;
width = '35px';
}
@ -33,6 +35,7 @@ export function RunButton(props: Props) {
value={refreshInterval}
isLoading={loading}
text={text}
tooltip={tooltip}
intervals={intervals}
isLive={isLive}
onRefresh={() => onRun(loading)}

View File

@ -1,32 +1,12 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import Wrapper from './Wrapper';
import { configureStore } from '../../store/configureStore';
import { Provider } from 'react-redux';
import { locationService, setDataSourceSrv, setEchoSrv } from '@grafana/runtime';
import {
ArrayDataFrame,
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
FieldType,
QueryEditorProps,
ScopedVars,
serializeStateToUrlParam,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { from, Observable } from 'rxjs';
import { LokiDatasource } from '../../plugins/datasource/loki/datasource';
import { LokiQuery } from '../../plugins/datasource/loki/types';
import { fromPairs } from 'lodash';
import { EnhancedStore } from '@reduxjs/toolkit';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { locationService } from '@grafana/runtime';
import { serializeStateToUrlParam } from '@grafana/data';
import userEvent from '@testing-library/user-event';
import { splitOpen } from './state/main';
import { Route, Router } from 'react-router-dom';
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
import { initialUserState } from '../profile/state/reducers';
import { Echo } from 'app/core/services/echo/Echo';
import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup';
import { makeLogsQueryResponse, makeMetricsQueryResponse } from './spec/helper/query';
import { changeDatasource } from './spec/helper/interactions';
type Mock = jest.Mock;
@ -52,17 +32,19 @@ jest.mock('react-virtualized-auto-sizer', () => {
});
describe('Wrapper', () => {
afterEach(() => {
tearDown();
});
it('shows warning if there are no data sources', async () => {
setup({ datasources: [] });
setupExplore({ datasources: [] });
// Will throw if isn't found
screen.getByText(/Explore requires at least one data source/i);
});
it('inits url and renders editor but does not call query on empty url', async () => {
const { datasources } = setup();
// Wait for rendering the editor
await screen.findByText(/Editor/i);
const { datasources } = setupExplore();
await waitForExplore();
// At this point url should be initialised to some defaults
expect(locationService.getSearchObject()).toEqual({
@ -77,14 +59,14 @@ describe('Wrapper', () => {
});
it('runs query when url contains query and renders results', async () => {
const query = {
const urlParams = {
left: serializeStateToUrlParam({
datasource: 'loki',
queries: [{ refId: 'A', expr: '{ label="value"}' }],
range: { from: 'now-1h', to: 'now' },
}),
};
const { datasources, store } = setup({ query });
const { datasources, store } = setupExplore({ urlParams });
(datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse());
// Make sure we render the logs panel
@ -99,7 +81,7 @@ describe('Wrapper', () => {
// We did not change the url
expect(locationService.getSearchObject()).toEqual({
orgId: '1',
...query,
...urlParams,
});
expect(store.getState().explore.richHistory[0]).toMatchObject({
@ -115,8 +97,8 @@ describe('Wrapper', () => {
});
it('handles url change and runs the new query', async () => {
const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) };
const { datasources } = setup({ query });
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) };
const { datasources } = setupExplore({ urlParams });
(datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse());
// Wait for rendering the logs
await screen.findByText(/custom log line/i);
@ -134,8 +116,8 @@ describe('Wrapper', () => {
});
it('handles url change and runs the new query with different datasource', async () => {
const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) };
const { datasources } = setup({ query });
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]) };
const { datasources } = setupExplore({ urlParams });
(datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse());
// Wait for rendering the logs
await screen.findByText(/custom log line/i);
@ -154,11 +136,10 @@ describe('Wrapper', () => {
});
it('handles changing the datasource manually', async () => {
const query = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) };
const { datasources } = setup({ query });
const urlParams = { left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}', refId: 'A' }]) };
const { datasources } = setupExplore({ urlParams });
(datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse());
// Wait for rendering the editor
await screen.findByText(/Editor/i);
await waitForExplore();
await changeDatasource('elastic');
await screen.findByText('elastic Editor input:');
@ -174,7 +155,7 @@ describe('Wrapper', () => {
});
it('opens the split pane when split button is clicked', async () => {
setup();
setupExplore();
// Wait for rendering the editor
const splitButton = await screen.findByText(/split/i);
fireEvent.click(splitButton);
@ -185,7 +166,7 @@ describe('Wrapper', () => {
});
it('inits with two panes if specified in url', async () => {
const query = {
const urlParams = {
left: serializeStateToUrlParam({
datasource: 'loki',
queries: [{ refId: 'A', expr: '{ label="value"}' }],
@ -198,7 +179,7 @@ describe('Wrapper', () => {
}),
};
const { datasources } = setup({ query });
const { datasources } = setupExplore({ urlParams });
(datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse());
(datasources.elastic.query as Mock).mockReturnValueOnce(makeLogsQueryResponse());
@ -219,7 +200,7 @@ describe('Wrapper', () => {
// We did not change the url
expect(locationService.getSearchObject()).toEqual({
orgId: '1',
...query,
...urlParams,
});
// We called the data source query method once
@ -235,11 +216,11 @@ describe('Wrapper', () => {
});
it('can close a pane from a split', async () => {
const query = {
const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { refId: 'A' }]),
right: JSON.stringify(['now-1h', 'now', 'elastic', { refId: 'A' }]),
};
setup({ query });
setupExplore({ urlParams });
const closeButtons = await screen.findAllByTitle(/Close split pane/i);
userEvent.click(closeButtons[1]);
@ -250,10 +231,10 @@ describe('Wrapper', () => {
});
it('handles url change to split view', async () => {
const query = {
const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
};
const { datasources } = setup({ query });
const { datasources } = setupExplore({ urlParams });
(datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse());
(datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse());
@ -268,10 +249,10 @@ describe('Wrapper', () => {
});
it('handles opening split with split open func', async () => {
const query = {
const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
};
const { datasources, store } = setup({ query });
const { datasources, store } = setupExplore({ urlParams });
(datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse());
(datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse());
@ -287,10 +268,10 @@ describe('Wrapper', () => {
});
it('changes the document title of the explore page to include the datasource in use', async () => {
const query = {
const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
};
const { datasources } = setup({ query });
const { datasources } = setupExplore({ urlParams });
(datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse());
// This is mainly to wait for render so that the left pane state is initialized as that is needed for the title
// to include the datasource
@ -299,10 +280,10 @@ describe('Wrapper', () => {
await waitFor(() => expect(document.title).toEqual('Explore - loki - Grafana'));
});
it('changes the document title to include the two datasources in use in split view mode', async () => {
const query = {
const urlParams = {
left: JSON.stringify(['now-1h', 'now', 'loki', { expr: '{ label="value"}' }]),
};
const { datasources, store } = setup({ query });
const { datasources, store } = setupExplore({ urlParams });
(datasources.loki.query as Mock).mockReturnValue(makeLogsQueryResponse());
(datasources.elastic.query as Mock).mockReturnValue(makeLogsQueryResponse());
@ -314,142 +295,10 @@ describe('Wrapper', () => {
await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana'));
});
it('removes `from` and `to` parameters from url when first mounted', () => {
setup({ searchParams: 'from=1&to=2&orgId=1' });
it('removes `from` and `to` parameters from url when first mounted', async () => {
setupExplore({ searchParams: 'from=1&to=2&orgId=1' });
expect(locationService.getSearchObject()).toEqual(expect.not.objectContaining({ from: '1', to: '2' }));
expect(locationService.getSearchObject()).toEqual(expect.objectContaining({ orgId: '1' }));
});
});
type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi };
type SetupOptions = {
datasources?: DatasourceSetup[];
query?: any;
searchParams?: string;
};
function setup(options?: SetupOptions): { datasources: { [name: string]: DataSourceApi }; store: EnhancedStore } {
// Clear this up otherwise it persists data source selection
// TODO: probably add test for that too
window.localStorage.clear();
// Create this here so any mocks are recreated on setup and don't retain state
const defaultDatasources: DatasourceSetup[] = [
makeDatasourceSetup(),
makeDatasourceSetup({ name: 'elastic', id: 2 }),
];
const dsSettings = options?.datasources || defaultDatasources;
setDataSourceSrv({
getList(): DataSourceInstanceSettings[] {
return dsSettings.map((d) => d.settings);
},
getInstanceSettings(name: string) {
return dsSettings.map((d) => d.settings).find((x) => x.name === name || x.uid === name);
},
get(name?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi> {
return Promise.resolve(
(name ? dsSettings.find((d) => d.api.name === name || d.api.uid === name) : dsSettings[0])!.api
);
},
} as any);
setEchoSrv(new Echo());
const store = configureStore();
store.getState().user = {
...initialUserState,
orgId: 1,
timeZone: 'utc',
};
store.getState().navIndex = {
explore: {
id: 'explore',
text: 'Explore',
subTitle: 'Explore your data',
icon: 'compass',
url: '/explore',
},
};
locationService.push({ pathname: '/explore', search: options?.searchParams });
if (options?.query) {
locationService.partial(options.query);
}
const route = { component: Wrapper };
render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<Route path="/explore" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
</Router>
</Provider>
);
return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store };
}
function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: number } = {}): DatasourceSetup {
const meta: any = {
info: {
logos: {
small: '',
},
},
id: id.toString(),
};
return {
settings: {
id,
uid: name,
type: 'logs',
name,
meta,
access: 'proxy',
jsonData: {},
},
api: {
components: {
QueryEditor(props: QueryEditorProps<LokiDatasource, LokiQuery>) {
return (
<div>
{name} Editor input: {props.query.expr}
</div>
);
},
},
name: name,
uid: name,
query: jest.fn(),
getRef: jest.fn(),
meta,
} as any,
};
}
function makeLogsQueryResponse(marker = ''): Observable<DataQueryResponse> {
const df = new ArrayDataFrame([{ ts: Date.now(), line: `custom log line ${marker}` }]);
df.meta = {
preferredVisualisationType: 'logs',
};
df.fields[0].type = FieldType.time;
return from([{ data: [df] }]);
}
function makeMetricsQueryResponse(): Observable<DataQueryResponse> {
const df = new ArrayDataFrame([{ ts: Date.now(), val: 1 }]);
df.fields[0].type = FieldType.time;
return from([{ data: [df] }]);
}
async function changeDatasource(name: string) {
const datasourcePicker = (await screen.findByLabelText(selectors.components.DataSourcePicker.container)).children[0];
fireEvent.keyDown(datasourcePicker, { keyCode: 40 });
const option = screen.getByText(name);
fireEvent.click(option);
}

View File

@ -0,0 +1,7 @@
import { screen } from '@testing-library/react';
export const assertQueryHistoryExists = (query: string) => {
expect(screen.getByText('1 queries')).toBeInTheDocument();
const queryItem = screen.getByLabelText('Query text');
expect(queryItem).toHaveTextContent(query);
};

View File

@ -0,0 +1,28 @@
import { selectors } from '@grafana/e2e-selectors';
import userEvent from '@testing-library/user-event';
import { fireEvent, screen } from '@testing-library/react';
export const changeDatasource = async (name: string) => {
const datasourcePicker = (await screen.findByLabelText(selectors.components.DataSourcePicker.container)).children[0];
fireEvent.keyDown(datasourcePicker, { keyCode: 40 });
const option = screen.getByText(name);
fireEvent.click(option);
};
export const inputQuery = (query: string) => {
const input = screen.getByRole('textbox', { name: 'query' });
userEvent.type(input, query);
};
export const runQuery = () => {
const button = screen.getByRole('button', { name: /run query/i });
userEvent.click(button);
};
export const openQueryHistory = async () => {
const button = screen.getByRole('button', { name: 'Rich history button' });
userEvent.click(button);
expect(
await screen.findByText('The history is local to your browser and is not shared with others.')
).toBeInTheDocument();
};

View File

@ -0,0 +1,17 @@
import { from, Observable } from 'rxjs';
import { ArrayDataFrame, DataQueryResponse, FieldType } from '@grafana/data';
export function makeLogsQueryResponse(marker = ''): Observable<DataQueryResponse> {
const df = new ArrayDataFrame([{ ts: Date.now(), line: `custom log line ${marker}` }]);
df.meta = {
preferredVisualisationType: 'logs',
};
df.fields[0].type = FieldType.time;
return from([{ data: [df] }]);
}
export function makeMetricsQueryResponse(): Observable<DataQueryResponse> {
const df = new ArrayDataFrame([{ ts: Date.now(), val: 1 }]);
df.fields[0].type = FieldType.time;
return from([{ data: [df] }]);
}

View File

@ -0,0 +1,152 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { EnhancedStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { Route, Router } from 'react-router-dom';
import { fromPairs } from 'lodash';
import { DataSourceApi, DataSourceInstanceSettings, QueryEditorProps, ScopedVars } from '@grafana/data';
import { locationService, setDataSourceSrv, setEchoSrv } from '@grafana/runtime';
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
import { Echo } from 'app/core/services/echo/Echo';
import { configureStore } from 'app/store/configureStore';
import Wrapper from '../../Wrapper';
import { initialUserState } from '../../../profile/state/reducers';
import { LokiDatasource } from '../../../../plugins/datasource/loki/datasource';
import { LokiQuery } from '../../../../plugins/datasource/loki/types';
type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi };
type SetupOptions = {
// default true
clearLocalStorage?: boolean;
datasources?: DatasourceSetup[];
urlParams?: { left: string; right?: string };
searchParams?: string;
};
export function setupExplore(options?: SetupOptions): {
datasources: { [name: string]: DataSourceApi };
store: EnhancedStore;
unmount: () => void;
} {
// Clear this up otherwise it persists data source selection
// TODO: probably add test for that too
if (options?.clearLocalStorage !== false) {
window.localStorage.clear();
}
// Create this here so any mocks are recreated on setup and don't retain state
const defaultDatasources: DatasourceSetup[] = [
makeDatasourceSetup(),
makeDatasourceSetup({ name: 'elastic', id: 2 }),
];
const dsSettings = options?.datasources || defaultDatasources;
setDataSourceSrv({
getList(): DataSourceInstanceSettings[] {
return dsSettings.map((d) => d.settings);
},
getInstanceSettings(name: string) {
return dsSettings.map((d) => d.settings).find((x) => x.name === name || x.uid === name);
},
get(name?: string | null, scopedVars?: ScopedVars): Promise<DataSourceApi> {
return Promise.resolve(
(name ? dsSettings.find((d) => d.api.name === name || d.api.uid === name) : dsSettings[0])!.api
);
},
} as any);
setEchoSrv(new Echo());
const store = configureStore();
store.getState().user = {
...initialUserState,
orgId: 1,
timeZone: 'utc',
};
store.getState().navIndex = {
explore: {
id: 'explore',
text: 'Explore',
subTitle: 'Explore your data',
icon: 'compass',
url: '/explore',
},
};
locationService.push({ pathname: '/explore', search: options?.searchParams });
if (options?.urlParams) {
locationService.partial(options.urlParams);
}
const route = { component: Wrapper };
const { unmount } = render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<Route path="/explore" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
</Router>
</Provider>
);
return { datasources: fromPairs(dsSettings.map((d) => [d.api.name, d.api])), store, unmount };
}
function makeDatasourceSetup({ name = 'loki', id = 1 }: { name?: string; id?: number } = {}): DatasourceSetup {
const meta: any = {
info: {
logos: {
small: '',
},
},
id: id.toString(),
};
return {
settings: {
id,
uid: name,
type: 'logs',
name,
meta,
access: 'proxy',
jsonData: {},
},
api: {
components: {
QueryEditor(props: QueryEditorProps<LokiDatasource, LokiQuery>) {
return (
<div>
<input
aria-label="query"
defaultValue={props.query.expr}
onChange={(event) => {
props.onChange({ ...props.query, expr: event.target.value });
}}
/>
{name} Editor input: {props.query.expr}
</div>
);
},
},
name: name,
uid: name,
query: jest.fn(),
getRef: jest.fn(),
meta,
} as any,
};
}
export const waitForExplore = async () => {
await screen.findByText(/Editor/i);
};
export const tearDown = () => {
window.localStorage.clear();
};

View File

@ -0,0 +1,47 @@
import React from 'react';
import { setupExplore, tearDown, waitForExplore } from './helper/setup';
import { inputQuery, openQueryHistory, runQuery } from './helper/interactions';
import { assertQueryHistoryExists } from './helper/assert';
import { makeLogsQueryResponse } from './helper/query';
jest.mock('react-virtualized-auto-sizer', () => {
return {
__esModule: true,
default(props: any) {
return <div>{props.children({ width: 1000 })}</div>;
},
};
});
describe('Explore: Query History', () => {
const USER_INPUT = 'my query';
const RAW_QUERY = `{"expr":"${USER_INPUT}"}`;
afterEach(() => {
tearDown();
});
it('adds new query history items after the query is run.', async () => {
// when Explore is opened
const { datasources, unmount } = setupExplore();
(datasources.loki.query as jest.Mock).mockReturnValueOnce(makeLogsQueryResponse());
await waitForExplore();
// and a user runs a query and opens query history
inputQuery(USER_INPUT);
runQuery();
await openQueryHistory();
// the query that was run is in query history
assertQueryHistoryExists(RAW_QUERY);
// when Explore is opened again
unmount();
setupExplore({ clearLocalStorage: false });
await waitForExplore();
// previously added query is in query history
await openQueryHistory();
assertQueryHistoryExists(RAW_QUERY);
});
});