import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ComponentProps } from 'react'; import { Provider } from 'react-redux'; import { DataFrame, EventBusSrv, ExplorePanelsState, LoadingState, LogLevel, LogRowModel, standardTransformersRegistry, toUtc, createDataFrame, ExploreLogsPanelState, DataQuery, } from '@grafana/data'; import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize'; import { config } from '@grafana/runtime'; import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen'; import { configureStore } from 'app/store/configureStore'; import { initialExploreState } from '../state/main'; import { makeExplorePaneState } from '../state/utils'; import { Logs } from './Logs'; import { visualisationTypeKey } from './utils/logs'; import { getMockElasticFrame, getMockLokiFrame } from './utils/testMocks.test'; const reportInteraction = jest.fn(); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), reportInteraction: (interactionName: string, properties?: Record | undefined) => reportInteraction(interactionName, properties), })); const createAndCopyShortLink = jest.fn(); jest.mock('app/core/utils/shortLinks', () => ({ ...jest.requireActual('app/core/utils/shortLinks'), createAndCopyShortLink: (url: string) => createAndCopyShortLink(url), })); const fakeChangePanelState = jest.fn().mockReturnValue({ type: 'fakeAction' }); jest.mock('../state/explorePane', () => ({ ...jest.requireActual('../state/explorePane'), changePanelState: (exploreId: string, panel: 'logs', panelState: {} | ExploreLogsPanelState) => { return fakeChangePanelState(exploreId, panel, panelState); }, })); const fakeChangeQueries = jest.fn().mockReturnValue({ type: 'fakeChangeQueries' }); const fakeRunQueries = jest.fn().mockReturnValue({ type: 'fakeRunQueries' }); jest.mock('../state/query', () => ({ ...jest.requireActual('../state/query'), changeQueries: (args: { queries: DataQuery[]; exploreId: string | undefined }) => { return fakeChangeQueries(args); }, runQueries: (args: { queries: DataQuery[]; exploreId: string | undefined }) => { return fakeRunQueries(args); }, })); describe('Logs', () => { let originalHref = window.location.href; beforeEach(() => { localStorage.clear(); jest.clearAllMocks(); }); beforeAll(() => { Object.defineProperty(window, 'location', { value: { href: 'http://localhost:3000/explore?test', }, writable: true, }); }); beforeAll(() => { const transformers = [extractFieldsTransformer, organizeFieldsTransformer]; standardTransformersRegistry.setInit(() => { return transformers.map((t) => { return { id: t.id, aliasIds: t.aliasIds, name: t.name, transformation: t, description: t.description, editor: () => null, }; }); }); }); afterAll(() => { Object.defineProperty(window, 'location', { value: { href: originalHref, }, writable: true, }); }); const getComponent = ( partialProps?: Partial>, dataFrame?: DataFrame, logs?: LogRowModel[] ) => { const rows = [ makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }), makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 2 }), makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 3 }), ]; const testDataFrame = dataFrame ?? getMockLokiFrame(); return ( undefined} logsVolumeEnabled={true} onSetLogsVolumeEnabled={() => null} onClickFilterLabel={() => null} onClickFilterOutLabel={() => null} logsVolumeData={undefined} loadLogsVolumeData={() => undefined} logRows={logs ?? rows} timeZone={'utc'} width={50} loading={false} loadingState={LoadingState.Done} absoluteRange={{ from: toUtc('2019-01-01 10:00:00').valueOf(), to: toUtc('2019-01-01 16:00:00').valueOf(), }} range={{ from: toUtc('2019-01-01 10:00:00'), to: toUtc('2019-01-01 16:00:00'), raw: { from: 'now-1h', to: 'now' }, }} addResultsToCache={() => {}} onChangeTime={() => {}} clearCache={() => {}} getFieldLinks={() => { return []; }} eventBus={new EventBusSrv()} isFilterLabelActive={jest.fn()} logsFrames={[testDataFrame]} {...partialProps} /> ); }; const setup = (partialProps?: Partial>, dataFrame?: DataFrame, logs?: LogRowModel[]) => { const fakeStore = configureStore({ explore: { ...initialExploreState, panes: { left: makeExplorePaneState(), }, }, }); const rendered = render( {getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs)} ); return { ...rendered, store: fakeStore }; }; describe('scrolling behavior', () => { let originalInnerHeight: number; beforeEach(() => { originalInnerHeight = window.innerHeight; window.innerHeight = 1000; window.HTMLElement.prototype.scrollIntoView = jest.fn(); window.HTMLElement.prototype.scroll = jest.fn(); }); afterEach(() => { window.innerHeight = originalInnerHeight; }); it('should call `scrollElement.scroll`', () => { const logs = []; for (let i = 0; i < 50; i++) { logs.push(makeLog({ uid: `uid${i}`, rowId: `id${i}`, timeEpochMs: i })); } const scrollElementMock = { scroll: jest.fn(), scrollTop: 920, }; setup( { scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState: { logs: { id: 'uid47' } } }, undefined, logs ); // element.getBoundingClientRect().top will always be 0 for jsdom // calc will be `scrollElement.scrollTop - window.innerHeight / 2` -> 920 - 500 = 420 expect(scrollElementMock.scroll).toBeCalledWith({ behavior: 'smooth', top: 420 }); }); }); it('should render logs', () => { setup(); const logsSection = screen.getByTestId('logRows'); let logRows = logsSection.querySelectorAll('tr'); expect(logRows.length).toBe(3); expect(logRows[0].textContent).toContain('log message 3'); expect(logRows[2].textContent).toContain('log message 1'); }); it('should render no logs found', () => { setup({}, undefined, []); expect(screen.getByText(/no logs found\./i)).toBeInTheDocument(); expect( screen.getByRole('button', { name: /scan for older logs/i, }) ).toBeInTheDocument(); }); it('should render a load more button', () => { const scanningStarted = jest.fn(); const store = configureStore({ explore: { ...initialExploreState, }, }); render( undefined} logsVolumeEnabled={true} onSetLogsVolumeEnabled={() => null} onClickFilterLabel={() => null} onClickFilterOutLabel={() => null} logsVolumeData={undefined} loadLogsVolumeData={() => undefined} logRows={[]} onStartScanning={scanningStarted} timeZone={'utc'} width={50} loading={false} loadingState={LoadingState.Done} absoluteRange={{ from: toUtc('2019-01-01 10:00:00').valueOf(), to: toUtc('2019-01-01 16:00:00').valueOf(), }} range={{ from: toUtc('2019-01-01 10:00:00'), to: toUtc('2019-01-01 16:00:00'), raw: { from: 'now-1h', to: 'now' }, }} addResultsToCache={() => {}} onChangeTime={() => {}} clearCache={() => {}} getFieldLinks={() => { return []; }} eventBus={new EventBusSrv()} isFilterLabelActive={jest.fn()} /> ); const button = screen.getByRole('button', { name: /scan for older logs/i, }); button.click(); expect(scanningStarted).toHaveBeenCalled(); }); it('should render a stop scanning button', () => { const store = configureStore({ explore: { ...initialExploreState, }, }); render( undefined} logsVolumeEnabled={true} onSetLogsVolumeEnabled={() => null} onClickFilterLabel={() => null} onClickFilterOutLabel={() => null} logsVolumeData={undefined} loadLogsVolumeData={() => undefined} logRows={[]} scanning={true} timeZone={'utc'} width={50} loading={false} loadingState={LoadingState.Done} absoluteRange={{ from: toUtc('2019-01-01 10:00:00').valueOf(), to: toUtc('2019-01-01 16:00:00').valueOf(), }} range={{ from: toUtc('2019-01-01 10:00:00'), to: toUtc('2019-01-01 16:00:00'), raw: { from: 'now-1h', to: 'now' }, }} addResultsToCache={() => {}} onChangeTime={() => {}} clearCache={() => {}} getFieldLinks={() => { return []; }} eventBus={new EventBusSrv()} isFilterLabelActive={jest.fn()} /> ); expect( screen.getByRole('button', { name: /stop scan/i, }) ).toBeInTheDocument(); }); it('should render a stop scanning button', () => { const scanningStopped = jest.fn(); const store = configureStore({ explore: { ...initialExploreState, }, }); render( undefined} logsVolumeEnabled={true} onSetLogsVolumeEnabled={() => null} onClickFilterLabel={() => null} onClickFilterOutLabel={() => null} logsVolumeData={undefined} loadLogsVolumeData={() => undefined} logRows={[]} scanning={true} onStopScanning={scanningStopped} timeZone={'utc'} width={50} loading={false} loadingState={LoadingState.Done} absoluteRange={{ from: toUtc('2019-01-01 10:00:00').valueOf(), to: toUtc('2019-01-01 16:00:00').valueOf(), }} range={{ from: toUtc('2019-01-01 10:00:00'), to: toUtc('2019-01-01 16:00:00'), raw: { from: 'now-1h', to: 'now' }, }} addResultsToCache={() => {}} onChangeTime={() => {}} clearCache={() => {}} getFieldLinks={() => { return []; }} eventBus={new EventBusSrv()} isFilterLabelActive={jest.fn()} /> ); const button = screen.getByRole('button', { name: /stop scan/i, }); button.click(); expect(scanningStopped).toHaveBeenCalled(); }); it('should flip the order', async () => { setup(); const oldestFirstSelection = screen.getByLabelText('Oldest first'); await userEvent.click(oldestFirstSelection); const logsSection = screen.getByTestId('logRows'); let logRows = logsSection.querySelectorAll('tr'); expect(logRows.length).toBe(3); expect(logRows[0].textContent).toContain('log message 1'); expect(logRows[2].textContent).toContain('log message 3'); expect(fakeRunQueries).not.toHaveBeenCalled(); }); it('should sync the query direction when changing the order of loki queries', async () => { const query = { expr: '{a="b"}', refId: 'A', datasource: { type: 'loki' } }; setup({ logsQueries: [query] }); const oldestFirstSelection = screen.getByLabelText('Oldest first'); await userEvent.click(oldestFirstSelection); expect(fakeChangeQueries).toHaveBeenCalledWith({ exploreId: 'left', queries: [{ ...query, direction: LokiQueryDirection.Forward }], }); expect(fakeRunQueries).toHaveBeenCalledWith({ exploreId: 'left' }); }); it('should not change the query direction when changing the order of non-loki queries', async () => { fakeChangeQueries.mockClear(); const query = { refId: 'B' }; setup({ logsQueries: [query] }); const oldestFirstSelection = screen.getByLabelText('Oldest first'); await userEvent.click(oldestFirstSelection); expect(fakeChangeQueries).not.toHaveBeenCalled(); }); describe('for permalinking', () => { it('should dispatch a `changePanelState` event without the id', () => { const panelState = { logs: { id: '1' } }; const { rerender, store } = setup({ loading: false, panelState }); rerender({getComponent({ loading: true, exploreId: 'right', panelState })}); rerender({getComponent({ loading: false, exploreId: 'right', panelState })}); expect(fakeChangePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} }); }); it('should scroll the scrollElement into view if rows contain id', () => { const panelState = { logs: { id: '3' } }; const scrollElementMock = { scroll: jest.fn() }; setup({ loading: false, scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState }); expect(scrollElementMock.scroll).toHaveBeenCalled(); }); it('should not scroll the scrollElement into view if rows does not contain id', () => { const panelState = { logs: { id: 'not-included' } }; const scrollElementMock = { scroll: jest.fn() }; setup({ loading: false, scrollElement: scrollElementMock as unknown as HTMLDivElement, panelState }); expect(scrollElementMock.scroll).not.toHaveBeenCalled(); }); it('should call reportInteraction on permalinkClick', async () => { const panelState = { logs: { id: 'not-included' } }; const rows = [ makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 4 }), makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 3 }), makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2 }), makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 1 }), ]; setup({ loading: false, panelState, logRows: rows }); const row = screen.getAllByRole('row'); await userEvent.hover(row[0]); const linkButton = screen.getByLabelText('Copy shortlink'); await userEvent.click(linkButton); expect(reportInteraction).toHaveBeenCalledWith('grafana_explore_logs_permalink_clicked', { datasourceType: 'unknown', logRowUid: '1', logRowLevel: 'debug', }); }); it('should call createAndCopyShortLink on permalinkClick - logs', async () => { const panelState: Partial = { logs: { id: 'not-included', visualisationType: 'logs', displayedFields: ['field'] }, }; const rows = [ makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1, labels: { field: '1' } }), makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1, labels: { field: '2' } }), makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2, labels: { field: '3' } }), makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2, labels: { field: '4' } }), ]; setup({ loading: false, panelState, logRows: rows }); const row = screen.getAllByRole('row'); await userEvent.hover(row[0]); const linkButton = screen.getByLabelText('Copy shortlink'); await userEvent.click(linkButton); expect(createAndCopyShortLink).toHaveBeenCalledWith( expect.stringMatching( 'range%22:%7B%22from%22:%222019-01-01T10:00:00.000Z%22,%22to%22:%222019-01-01T16:00:00.000Z%22%7D' ) ); expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs')); expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('displayedFields%22:%5B%22field')); }); it('should call createAndCopyShortLink on permalinkClick - with infinite scrolling', async () => { const featureToggleValue = config.featureToggles.logsInfiniteScrolling; config.featureToggles.logsInfiniteScrolling = true; const rows = [ makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }), makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1 }), makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2 }), makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2 }), ]; const panelState: Partial = { logs: { id: 'not-included', visualisationType: 'logs' } }; setup({ loading: false, panelState, logRows: rows }); const row = screen.getAllByRole('row'); await userEvent.hover(row[3]); const linkButton = screen.getByLabelText('Copy shortlink'); await userEvent.click(linkButton); expect(createAndCopyShortLink).toHaveBeenCalledWith( expect.stringMatching( 'range%22:%7B%22from%22:%222019-01-01T10:00:00.000Z%22,%22to%22:%221970-01-01T00:00:00.002Z%22%7D' ) ); expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs')); config.featureToggles.logsInfiniteScrolling = featureToggleValue; }); }); describe('displayed fields', () => { it('should sync displayed fields from the URL', async () => { const panelState: Partial = { logs: { id: 'not-included', visualisationType: 'logs', displayedFields: ['field'] }, }; const rows = [makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1, labels: { field: 'field value' } })]; setup({ loading: false, panelState, logRows: rows }); expect(await screen.findByText('field=field value')).toBeInTheDocument(); expect(screen.queryByText(/log message/)).not.toBeInTheDocument(); }); }); describe('with table visualisation', () => { let originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation; beforeAll(() => { originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation; config.featureToggles.logsExploreTableVisualisation = true; }); afterAll(() => { config.featureToggles.logsExploreTableVisualisation = originalVisualisationTypeValue; }); it('should show visualisation type radio group', () => { setup(); const logsSection = screen.getByRole('radio', { name: 'Show results in table visualisation' }); expect(logsSection).toBeInTheDocument(); }); it('should change visualisation to table on toggle (loki)', async () => { setup({}); const logsSection = screen.getByRole('radio', { name: 'Show results in table visualisation' }); await userEvent.click(logsSection); const table = screen.getByTestId('logRowsTable'); expect(table).toBeInTheDocument(); }); it('should use default state from localstorage - table', async () => { localStorage.setItem(visualisationTypeKey, 'table'); setup({}); const table = await screen.findByTestId('logRowsTable'); expect(table).toBeInTheDocument(); }); it('should use default state from localstorage - logs', async () => { localStorage.setItem(visualisationTypeKey, 'logs'); setup({}); const table = await screen.findByTestId('logRows'); expect(table).toBeInTheDocument(); }); it('should change visualisation to table on toggle (elastic)', async () => { setup({}, getMockElasticFrame()); const logsSection = screen.getByRole('radio', { name: 'Show results in table visualisation' }); await userEvent.click(logsSection); const table = screen.getByTestId('logRowsTable'); expect(table).toBeInTheDocument(); }); }); }); const makeLog = (overrides: Partial): LogRowModel => { const uid = overrides.uid || '1'; const entry = `log message ${uid}`; return { uid, entryFieldIndex: 0, rowIndex: 0, dataFrame: createDataFrame({ fields: [] }), logLevel: LogLevel.debug, entry, hasAnsi: false, hasUnescapedContent: false, labels: {}, raw: entry, timeFromNow: '', timeEpochMs: 1, timeEpochNs: '1000000', timeLocal: '', timeUtc: '', ...overrides, }; };