diff --git a/ui/jest.config.js b/ui/jest.config.js index a3d271c9b78..e1107c43d55 100644 --- a/ui/jest.config.js +++ b/ui/jest.config.js @@ -23,7 +23,7 @@ module.exports = { 'ts-jest': { tsConfig: 'tsconfig.test.json', diagnostics: { - ignoreCodes: [6133, 6192], // ignore unused variable errors + ignoreCodes: [6133, 6192] // ignore unused variable errors }, }, }, diff --git a/ui/jestSetup.ts b/ui/jestSetup.ts index 7f43c6b2207..82ee3888685 100644 --- a/ui/jestSetup.ts +++ b/ui/jestSetup.ts @@ -1,13 +1,31 @@ import {cleanup} from '@testing-library/react' import 'intersection-observer' import MutationObserver from 'mutation-observer' +import fetchMock from 'jest-fetch-mock' + +// global vars +process.env.API_PREFIX = 'http://example.com/' + +declare global { + interface Window { + flushAllPromises: () => Promise + MutationObserver: MutationObserver + } +} + // Adds MutationObserver as a polyfill for testing window.MutationObserver = MutationObserver +window.flushAllPromises = async () => { + return new Promise(resolve => setImmediate(resolve)) +} + +// mocks and stuff +fetchMock.enableMocks() jest.mock('honeybadger-js', () => () => null) -process.env.API_PREFIX = '/' // cleans up state between @testing-library/react tests afterEach(() => { cleanup() + fetchMock.resetMocks() }) diff --git a/ui/src/shared/apis/query.ts b/ui/src/shared/apis/query.ts index 4d069319dda..1ebe1cb6c56 100644 --- a/ui/src/shared/apis/query.ts +++ b/ui/src/shared/apis/query.ts @@ -75,9 +75,7 @@ export const runQuery = ( } } -export const processResponse = async ( - response: Response -): Promise => { +const processResponse = async (response: Response): Promise => { switch (response.status) { case 200: return processSuccessResponse(response) diff --git a/ui/src/timeMachine/actions/queries.ts b/ui/src/timeMachine/actions/queries.ts index cab2ca9159f..a40ab57db51 100644 --- a/ui/src/timeMachine/actions/queries.ts +++ b/ui/src/timeMachine/actions/queries.ts @@ -201,8 +201,9 @@ export const executeQueries = (abortController?: AbortController) => async ( dispatch( setQueryResults(RemoteDataState.Done, files, duration, null, statuses) ) + return results } catch (error) { - if (error.name === 'CancellationError') { + if (error.name === 'CancellationError' || error.name === 'AbortError') { dispatch(setQueryResults(RemoteDataState.Done, null, null)) return } diff --git a/ui/src/timeMachine/components/SubmitQueryButton.test.tsx b/ui/src/timeMachine/components/SubmitQueryButton.test.tsx index a284cae1d7d..93d127698e0 100644 --- a/ui/src/timeMachine/components/SubmitQueryButton.test.tsx +++ b/ui/src/timeMachine/components/SubmitQueryButton.test.tsx @@ -1,22 +1,71 @@ // Libraries import React from 'react' +import {mocked} from 'ts-jest/utils' +import {fireEvent} from '@testing-library/react' -// Components -import SubmitQueryButton from 'src/timeMachine/components/SubmitQueryButton' - -// Utils import {renderWithRedux} from 'src/mockState' -import {fireEvent, waitFor} from '@testing-library/react' - -// Types import {RemoteDataState} from 'src/types' +declare global { + interface Window { + TextDecoder: any + } +} + +class FakeTextDecoder { + decode() { + return '' + } +} + +window.TextDecoder = FakeTextDecoder + +jest.mock('src/external/parser', () => { + return { + parse: jest.fn(() => { + return { + type: 'File', + package: { + name: { + name: 'fake', + type: 'Identifier', + }, + type: 'PackageClause', + }, + imports: [], + body: [], + } + }), + } +}) + +jest.mock('src/variables/actions/thunks', () => { + return { + hydrateVariables: jest.fn(() => { + return (_dispatch, _getState) => { + return Promise.resolve() + } + }), + } +}) + +import SubmitQueryButton from 'src/timeMachine/components/SubmitQueryButton' + const stateOverride = { timeMachines: { activeTimeMachineID: 'veo', timeMachines: { veo: { - draftQueries: [{text: 'this is a draft query'}], + draftQueries: [ + { + text: `from(bucket: "apps") + |> range(start: v.timeRangeStart, stop: v.timeRangeStop) + |> filter(fn: (r) => r["_measurement"] == "rum") + |> filter(fn: (r) => r["_field"] == "domInteractive") + |> map(fn: (r) => ({r with _value: r._value / 1000.0})) + |> group()`, + }, + ], activeQueryIndex: 0, queryResults: { status: RemoteDataState.NotStarted, @@ -32,30 +81,122 @@ const stateOverride = { } describe('TimeMachine.Components.SubmitQueryButton', () => { - describe('if button is clicked', () => { - it('disables the submit button when no query is present', () => { - const {getByTitle} = renderWithRedux() - - const SubmitBtn = getByTitle('Submit') - fireEvent.click(SubmitBtn) - // expect the button to still be on submit - expect(getByTitle('Submit')).toBeTruthy() + beforeEach(() => { + jest.useFakeTimers() + }) + afterEach(() => { + jest.useRealTimers() + }) + + it('it changes the Submit button to Cancel when the request is in flight, then back to Submit after the request has resolved', async () => { + const fakeReader = { + cancel: jest.fn(), + read: jest.fn(() => { + return Promise.resolve({ + done: true, + }) + }), + } + + const fakeResponse = { + status: 200, + body: { + getReader: () => fakeReader, + }, + } + + const expectedMockedFetchCall = { + method: 'POST', + headers: {'Content-Type': 'application/json', 'Accept-Encoding': 'gzip'}, + body: JSON.stringify({ + query: stateOverride.timeMachines.timeMachines.veo.draftQueries[0].text, + extern: { + type: 'File', + package: null, + imports: null, + body: [ + { + type: 'OptionStatement', + assignment: { + type: 'VariableAssignment', + id: {type: 'Identifier', name: 'v'}, + init: { + type: 'ObjectExpression', + properties: [ + { + type: 'Property', + key: {type: 'Identifier', name: 'timeRangeStart'}, + value: { + type: 'UnaryExpression', + operator: '-', + argument: { + type: 'DurationLiteral', + values: [{magnitude: 1, unit: 'h'}], + }, + }, + }, + { + type: 'Property', + key: {type: 'Identifier', name: 'timeRangeStop'}, + value: { + type: 'CallExpression', + callee: {type: 'Identifier', name: 'now'}, + }, + }, + ], + }, + }, + }, + ], + }, + dialect: {annotations: ['group', 'datatype', 'default']}, + }), + signal: new AbortController().signal, + } + + mocked(fetch).mockImplementation(() => { + return Promise.resolve(fakeResponse) }) - it('allows the query to be cancelled after submission', async () => { - const {getByTitle} = renderWithRedux(, s => ({ - ...s, - ...stateOverride, - })) - - const SubmitBtn = getByTitle('Submit') - fireEvent.click(SubmitBtn) - - const CancelBtn = getByTitle('Cancel') - // expect the button to toggle to Cancel - expect(CancelBtn).toBeTruthy() - fireEvent.click(CancelBtn) - // aborts the query and returns the query to submit mode - expect(await waitFor(() => getByTitle('Submit'))).toBeTruthy() + const {getByTitle} = renderWithRedux(, s => ({ + ...s, + ...stateOverride, + })) + + fireEvent.click(getByTitle('Submit')) + expect(getByTitle('Cancel')).toBeTruthy() + await window.flushAllPromises() + + expect(mocked(fetch)).toHaveBeenCalledWith( + 'http://example.com/api/v2/query?orgID=orgid', + expectedMockedFetchCall + ) + expect(getByTitle('Submit')).toBeTruthy() + }) + + it("cancels the query after submission if the query hasn't finished and resolved", async () => { + mocked(fetchMock).mockResponse(() => { + return new Promise((resolve, _reject) => { + setTimeout(() => { + resolve('') + }, 3000) + }) }) + + const {getByTitle} = renderWithRedux(, s => ({ + ...s, + ...stateOverride, + })) + const SubmitBtn = getByTitle('Submit') + fireEvent.click(SubmitBtn) + + const CancelBtn = getByTitle('Cancel') + fireEvent.click(CancelBtn) + await window.flushAllPromises() + + const {type, value: error} = mocked(fetch).mock.results[0] as any + expect(type).toBe('throw') + expect(error.name).toBe('AbortError') + + expect(getByTitle('Submit')).toBeTruthy() }) }) diff --git a/ui/yarn.lock b/ui/yarn.lock index 21bca92c471..dcbaa4354ea 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -9358,14 +9358,14 @@ react-dnd@^2.6.0: prop-types "^15.5.10" react-dom@^16.8.2: - version "16.8.2" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.2.tgz#7c8a69545dd554d45d66442230ba04a6a0a3c3d3" - integrity sha512-cPGfgFfwi+VCZjk73buu14pYkYBR1b/SRMSYqkLDdhSEHnSwcuYTPu6/Bh6ZphJFIk80XLvbSe2azfcRzNF+Xg== + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" + integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.13.2" + scheduler "^0.19.1" react-draggable@3.x, "react-draggable@^2.2.6 || ^3.0.3": version "3.0.5" @@ -10123,10 +10123,10 @@ schedule@^0.5.0: dependencies: object-assign "^4.1.1" -scheduler@^0.13.2: - version "0.13.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.2.tgz#969eaee2764a51d2e97b20a60963b2546beff8fa" - integrity sha512-qK5P8tHS7vdEMCW5IPyt8v9MJOHqTrOUgPXib7tqm9vh834ibBX5BNhwkplX/0iOzHW5sXyluehYfS9yrkz9+w== +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1"