import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CoreApp, LogSortOrderChangeEvent, LogsSortOrder, store } from '@grafana/data'; import { config, getAppEvents } from '@grafana/runtime'; import { LokiQuery, LokiQueryDirection, LokiQueryType } from '../../types'; import { LokiQueryBuilderOptions, Props } from './LokiQueryBuilderOptions'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), config: { ...jest.requireActual('@grafana/runtime').config, featureToggles: { ...jest.requireActual('@grafana/runtime').featureToggles, lokiShardSplitting: true, }, }, getAppEvents: jest.fn(), })); const subscribeMock = jest.fn(); beforeAll(() => { config.featureToggles.lokiShardSplitting = true; subscribeMock.mockImplementation(() => ({ unsubscribe: jest.fn() })); jest.mocked(getAppEvents).mockReturnValue({ publish: jest.fn(), getStream: jest.fn(), subscribe: subscribeMock, removeAllListeners: jest.fn(), newScopedBus: jest.fn(), }); }); describe('LokiQueryBuilderOptions', () => { it('can change query type', async () => { const { props } = setup(); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.getByLabelText('Range')).toBeChecked(); await userEvent.click(screen.getByLabelText('Instant')); expect(props.onChange).toHaveBeenCalledWith({ ...props.query, queryType: LokiQueryType.Instant, }); }); it('can change legend format', async () => { const { props } = setup(); await userEvent.click(screen.getByRole('button', { name: /Options/ })); // First autosize input is a Legend const element = screen.getAllByTestId('autosize-input')[0]; await userEvent.type(element, 'asd'); await userEvent.keyboard('{enter}'); expect(props.onChange).toHaveBeenCalledWith({ ...props.query, legendFormat: 'asd', }); }); it('can change line limit to valid value', async () => { const { props } = setup({ expr: '{foo="bar"}' }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); // Second autosize input is a Line limit const element = screen.getAllByTestId('autosize-input')[1]; await userEvent.type(element, '10'); await userEvent.keyboard('{enter}'); expect(props.onChange).toHaveBeenCalledWith({ ...props.query, maxLines: 10, }); }); it('does not change line limit to invalid numeric value', async () => { const { props } = setup({ expr: '{foo="bar"}' }); // We need to start with some value to be able to change it props.query.maxLines = 10; await userEvent.click(screen.getByRole('button', { name: /Options/ })); // Second autosize input is a Line limit const element = screen.getAllByTestId('autosize-input')[1]; await userEvent.type(element, '-10'); await userEvent.keyboard('{enter}'); expect(props.onChange).toHaveBeenCalledWith({ ...props.query, maxLines: undefined, }); }); it('does not change line limit to invalid text value', async () => { const { props } = setup({ expr: '{foo="bar"}' }); // We need to start with some value to be able to change it props.query.maxLines = 10; await userEvent.click(screen.getByRole('button', { name: /Options/ })); // Second autosize input is a Line limit const element = screen.getAllByTestId('autosize-input')[1]; await userEvent.type(element, 'asd'); await userEvent.keyboard('{enter}'); expect(props.onChange).toHaveBeenCalledWith({ ...props.query, maxLines: undefined, }); }); it('shows correct options for log query', async () => { setup({ expr: '{foo="bar"}', direction: LokiQueryDirection.Backward }); expect(screen.getByText('Line limit: 20')).toBeInTheDocument(); expect(screen.getByText('Type: Range')).toBeInTheDocument(); expect(screen.getByText('Direction: Backward')).toBeInTheDocument(); expect(screen.queryByText(/step/i)).not.toBeInTheDocument(); }); it('shows correct options for metric query', async () => { setup({ expr: 'rate({foo="bar"}[5m]', step: '1m', resolution: 2 }); expect(screen.queryByText('Line limit: 20')).not.toBeInTheDocument(); expect(screen.getByText('Type: Range')).toBeInTheDocument(); expect(screen.getByText('Step: 1m')).toBeInTheDocument(); expect(screen.getByText('Resolution: 1/2')).toBeInTheDocument(); expect(screen.queryByText(/Direction/)).not.toBeInTheDocument(); }); it('does not show resolution field if resolution is not set', async () => { setup({ expr: 'rate({foo="bar"}[5m]' }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.queryByText('Resolution')).not.toBeInTheDocument(); }); it('does not show resolution field if resolution is set to default value 1', async () => { setup({ expr: 'rate({foo="bar"}[5m]', resolution: 1 }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.queryByText('Resolution')).not.toBeInTheDocument(); }); it('does shows resolution field with warning if resolution is set to non-default value', async () => { setup({ expr: 'rate({foo="bar"}[5m]', resolution: 2 }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.getByText('Resolution')).toBeInTheDocument(); expect( screen.getByText("The 'Resolution' is deprecated. Use 'Step' editor instead to change step parameter.") ).toBeInTheDocument(); }); it.each(['abc', 10])('shows correct options for metric query with invalid step', async (step: string | number) => { // @ts-expect-error Expected for backward compatibility test setup({ expr: 'rate({foo="bar"}[5m]', step }); expect(screen.queryByText('Line limit: 20')).not.toBeInTheDocument(); expect(screen.getByText('Type: Range')).toBeInTheDocument(); expect(screen.getByText('Step: Invalid value')).toBeInTheDocument(); }); it('shows error when invalid value in step', async () => { setup({ expr: 'rate({foo="bar"}[5m]', step: 'a' }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.getByText(/Invalid step/)).toBeInTheDocument(); }); it('does not show error when valid value in step', async () => { setup({ expr: 'rate({foo="bar"}[5m]', step: '1m' }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.queryByText(/Invalid step/)).not.toBeInTheDocument(); }); it('does not show error when valid millisecond value in step', async () => { setup({ expr: 'rate({foo="bar"}[5m]', step: '1ms' }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.queryByText(/Invalid step/)).not.toBeInTheDocument(); }); it('does not show error when valid day value in step', async () => { setup({ expr: 'rate({foo="bar"}[5m]', step: '1d' }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.queryByText(/Invalid step/)).not.toBeInTheDocument(); }); it('does not show instant type when using a log query', async () => { setup({ expr: '{foo="bar"}', queryType: LokiQueryType.Instant }); expect(screen.queryByText(/Instant/)).not.toBeInTheDocument(); }); it('does not show instant type in the options when using a log query', async () => { setup({ expr: '{foo="bar"}', step: '1m' }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.queryByText(/Instant/)).not.toBeInTheDocument(); }); it('allows to clear step input', async () => { setup({ expr: 'rate({foo="bar"}[5m]', step: '4s' }); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(screen.getByDisplayValue('4s')).toBeInTheDocument(); await userEvent.clear(screen.getByDisplayValue('4s')); expect(screen.queryByDisplayValue('4s')).not.toBeInTheDocument(); }); it('should transform non duration numbers to duration', async () => { const onChange = jest.fn(); setup({ expr: 'rate({foo="bar"}[5m]', step: '4' }, onChange); await userEvent.click(screen.getByRole('button', { name: /Options/ })); expect(onChange).toHaveBeenCalledWith({ refId: 'A', expr: 'rate({foo="bar"}[5m]', step: '4s', }); }); describe('Query direction', () => { it("initializes query direction when it's empty in Explore or Dashboards", () => { const onChange = jest.fn(); setup({ expr: '{foo="bar"}' }, onChange, { app: CoreApp.Explore }); expect(onChange).toHaveBeenCalledWith({ expr: '{foo="bar"}', refId: 'A', direction: LokiQueryDirection.Backward, }); }); it('does not change direction on initialization elsewhere', () => { const onChange = jest.fn(); setup({ expr: '{foo="bar"}' }, onChange, { app: undefined }); expect(onChange).not.toHaveBeenCalled(); }); it('uses backward as default in Explore with no previous stored preference', () => { const onChange = jest.fn(); store.delete('grafana.explore.logs.sortOrder'); setup({ expr: '{foo="bar"}' }, onChange, { app: CoreApp.Explore }); expect(onChange).toHaveBeenCalledWith({ expr: '{foo="bar"}', refId: 'A', direction: LokiQueryDirection.Backward, }); }); it('uses the stored sorting option to determine direction in Explore', () => { store.set('grafana.explore.logs.sortOrder', LogsSortOrder.Ascending); const onChange = jest.fn(); setup({ expr: '{foo="bar"}' }, onChange, { app: CoreApp.Explore }); expect(onChange).toHaveBeenCalledWith({ expr: '{foo="bar"}', refId: 'A', direction: LokiQueryDirection.Forward, }); store.delete('grafana.explore.logs.sortOrder'); }); describe('Event handling', () => { let listener: (event: LogSortOrderChangeEvent) => void = jest.fn(); const onChangeMock = jest.fn(); beforeEach(() => { onChangeMock.mockClear(); listener = jest.fn(); subscribeMock.mockImplementation((_: unknown, callback: (event: LogSortOrderChangeEvent) => void) => { listener = callback; return { unsubscribe: jest.fn() }; }); }); it('subscribes to sort change event and updates the direction', () => { setup({ expr: '{foo="bar"}', direction: LokiQueryDirection.Backward }, onChangeMock, { app: CoreApp.Dashboard, }); expect(screen.getByText(/Direction: Backward/)).toBeInTheDocument(); listener( new LogSortOrderChangeEvent({ order: LogsSortOrder.Ascending, }) ); expect(onChangeMock).toHaveBeenCalledTimes(1); expect(onChangeMock).toHaveBeenCalledWith({ direction: 'forward', expr: '{foo="bar"}', refId: 'A', }); }); it('does not change the direction when the current direction is scan', () => { setup({ expr: '{foo="bar"}', direction: LokiQueryDirection.Scan }, onChangeMock, { app: CoreApp.Dashboard }); expect(screen.getByText(/Direction: Scan/)).toBeInTheDocument(); listener( new LogSortOrderChangeEvent({ order: LogsSortOrder.Ascending, }) ); expect(onChangeMock).not.toHaveBeenCalled(); }); }); }); }); function setup(queryOverrides: Partial = {}, onChange = jest.fn(), propOverrides: Partial = {}) { const props = { query: { refId: 'A', expr: '', ...queryOverrides, }, onRunQuery: jest.fn(), onChange, maxLines: 20, queryStats: { streams: 0, chunks: 0, bytes: 0, entries: 0 }, ...propOverrides, }; const { container } = render(); return { container, props }; }