-
Tom Robinson authored
This reverts commit dec7ff44.
Tom Robinson authoredThis reverts commit dec7ff44.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
integrated_tests.js 22.23 KiB
/* global process, jasmine */
/**
* Import this file before other imports in integrated tests
*/
// Mocks in a separate file as they would clutter this file
// This must be before all other imports
import "./mocks";
import { format as urlFormat } from "url";
import api from "metabase/lib/api";
import { defer, delay } from "metabase/lib/promise";
import {
DashboardApi,
SessionApi,
CardApi,
MetricApi,
SegmentApi,
CollectionsApi,
PermissionsApi,
} from "metabase/services";
import { METABASE_SESSION_COOKIE } from "metabase/lib/cookies";
import normalReducers from "metabase/reducers-main";
import publicReducers from "metabase/reducers-public";
import React from "react";
import { Provider } from "react-redux";
import { createMemoryHistory } from "history";
import { getStore } from "metabase/store";
import { createRoutes, Router, useRouterHistory } from "react-router";
import _ from "underscore";
import chalk from "chalk";
import moment from "moment";
import EventEmitter from "events";
const events = new EventEmitter();
// Importing isomorphic-fetch sets the global `fetch` and `Headers` objects that are used here
import fetch from "isomorphic-fetch";
import { refreshSiteSettings } from "metabase/redux/settings";
import { getRoutes as getNormalRoutes } from "metabase/routes";
import { getRoutes as getPublicRoutes } from "metabase/routes-public";
import { getRoutes as getEmbedRoutes } from "metabase/routes-embed";
let hasStartedCreatingStore = false;
let hasFinishedCreatingStore = false;
let loginSession = null; // Stores the current login session
let previousLoginSession = null;
let simulateOfflineMode = false;
let apiRequestCompletedCallback = null;
let skippedApiRequests = [];
// These i18n settings are same is beginning of app.js
// make the i18n function "t" global so we don't have to import it in basically every file
import { t, jt } from "c-3po";
global.t = t;
global.jt = jt;
// set the locale before loading anything else
import { setLocalization } from "metabase/lib/i18n";
if (window.MetabaseLocalization) {
setLocalization(window.MetabaseLocalization);
}
const warnAboutCreatingStoreBeforeLogin = () => {
if (!loginSession && hasStartedCreatingStore) {
console.warn(
"Warning: You have created a test store before calling logging in which means that up-to-date site settings " +
"won't be in the store unless you call `refreshSiteSettings` action manually. Please prefer " +
"logging in before all tests and creating the store inside an individual test or describe block.",
);
}
};
/**
* Login to the Metabase test instance with default credentials
*/
export async function login({
username = "bob@metabase.com",
password = "12341234",
} = {}) {
warnAboutCreatingStoreBeforeLogin();
loginSession = await SessionApi.create({ username, password });
}
export function useSharedAdminLogin() {
warnAboutCreatingStoreBeforeLogin();
loginSession = { id: process.env.TEST_FIXTURE_SHARED_ADMIN_LOGIN_SESSION_ID };
}
export function useSharedNormalLogin() {
warnAboutCreatingStoreBeforeLogin();
loginSession = {
id: process.env.TEST_FIXTURE_SHARED_NORMAL_LOGIN_SESSION_ID,
};
}
export const forBothAdminsAndNormalUsers = tests => {
describe("for admins", () => {
beforeEach(useSharedAdminLogin);
tests();
});
describe("for normal users", () => {
beforeEach(useSharedNormalLogin);
tests();
});
};
export function logout() {
previousLoginSession = loginSession;
loginSession = null;
}
/**
* Lets you recover the previous login session after calling logout
*/
export function restorePreviousLogin() {
if (previousLoginSession) {
loginSession = previousLoginSession;
} else {
console.warn("There is no previous login that could be restored!");
}
}
/**
* Calls the provided function while simulating that the browser is offline
*/
export async function whenOffline(callWhenOffline) {
try {
simulateOfflineMode = true;
return await callWhenOffline();
} finally {
simulateOfflineMode = false;
}
}
export function switchToPlainDatabase() {
api.basename = process.env.PLAIN_BACKEND_HOST;
}
export function switchToTestFixtureDatabase() {
api.basename = process.env.TEST_FIXTURE_BACKEND_HOST;
}
export const isPlainDatabase = () =>
api.basename === process.env.PLAIN_BACKEND_HOST;
export const isTestFixtureDatabase = () =>
api.basename === process.env.TEST_FIXTURE_BACKEND_HOST;
/**
* Creates an augmented Redux store for testing the whole app including browser history manipulation. Includes:
* - A simulated browser history that is used by react-router
* - Methods for
* * manipulating the simulated browser history
* * waiting until specific Redux actions have been dispatched
* * getting a React container subtree for the current route
*/
export const createTestStore = async ({
publicApp = false,
embedApp = false,
} = {}) => {
hasFinishedCreatingStore = false;
hasStartedCreatingStore = true;
const history = useRouterHistory(createMemoryHistory)();
const getRoutes = publicApp
? getPublicRoutes
: embedApp ? getEmbedRoutes : getNormalRoutes;
const reducers = publicApp || embedApp ? publicReducers : normalReducers;
const store = getStore(reducers, history, undefined, createStore =>
testStoreEnhancer(createStore, history, getRoutes),
);
store._setFinalStoreInstance(store);
if (!publicApp) {
await store.dispatch(refreshSiteSettings());
}
hasFinishedCreatingStore = true;
return store;
};
/**
* History state change events you can listen to in tests
*/
export const BROWSER_HISTORY_PUSH = `integrated-tests/BROWSER_HISTORY_PUSH`;
export const BROWSER_HISTORY_REPLACE = `integrated-tests/BROWSER_HISTORY_REPLACE`;
export const BROWSER_HISTORY_POP = `integrated-tests/BROWSER_HISTORY_POP`;
const testStoreEnhancer = (createStore, history, getRoutes) => {
return (...args) => {
const store = createStore(...args);
// Because we don't have an access to internal actions of react-router,
// let's create synthetic actions from actual history changes instead
history.listen(location => {
store.dispatch({
type: `integrated-tests/BROWSER_HISTORY_${location.action}`,
location: location,
});
});
const testStoreExtensions = {
_originalDispatch: store.dispatch,
_onActionDispatched: null,
_allDispatchedActions: [],
_latestDispatchedActions: [],
_finalStoreInstance: null,
/**
* Redux dispatch method middleware that records all dispatched actions
*/
dispatch: action => {
events.emit("action", action);
const result = store._originalDispatch(action);
const actionWithTimestamp = [
{
...action,
timestamp: Date.now(),
},
];
store._allDispatchedActions = store._allDispatchedActions.concat(
actionWithTimestamp,
);
store._latestDispatchedActions = store._latestDispatchedActions.concat(
actionWithTimestamp,
);
if (store._onActionDispatched) store._onActionDispatched();
return result;
},
/**
* Waits until all actions with given type identifiers have been called or fails if the maximum waiting
* time defined in `timeout` is exceeded.
*
* Convenient in tests for waiting specific actions to be executed after mounting a React container.
*/
waitForActions: (actionTypes, { timeout = 8000 } = {}) => {
if (store._onActionDispatched) {
return Promise.reject(
new Error(
"You have an earlier `store.waitForActions(...)` still in progress – have you forgotten to prepend `await` to the method call?",
),
);
}
actionTypes = Array.isArray(actionTypes) ? actionTypes : [actionTypes];
if (_.any(actionTypes, type => !type)) {
return Promise.reject(
new Error(
`You tried to wait for a null or undefined action type (${actionTypes})`,
),
);
}
// supports redux-action style action creator that when cast to a string returns the action name
actionTypes = actionTypes.map(actionType => String(actionType));
// Returns all actions that are triggered after the last action which belongs to `actionTypes
const getRemainingActions = () => {
const lastActionIndex = _.findLastIndex(
store._latestDispatchedActions,
action => actionTypes.includes(action.type),
);
return store._latestDispatchedActions.slice(lastActionIndex + 1);
};
const allActionsAreTriggered = () =>
_.every(
actionTypes,
actionType =>
store._latestDispatchedActions.filter(
action => action.type === actionType,
).length > 0,
);
if (allActionsAreTriggered()) {
// Short-circuit if all action types are already in the history of dispatched actions
store._latestDispatchedActions = getRemainingActions();
return Promise.resolve();
} else {
return new Promise((resolve, reject) => {
const timeoutID = setTimeout(() => {
store._onActionDispatched = null;
return reject(
new Error(
`All these actions were not dispatched within ${timeout}ms:\n` +
chalk.cyan(actionTypes.join("\n")) +
"\n\nDispatched actions since the last call of `waitForActions`:\n" +
(store._latestDispatchedActions
.map(store._formatDispatchedAction)
.join("\n") || "No dispatched actions") +
"\n\nDispatched actions since the initialization of test suite:\n" +
(store._allDispatchedActions
.map(store._formatDispatchedAction)
.join("\n") || "No dispatched actions"),
),
);
}, timeout);
store._onActionDispatched = () => {
if (allActionsAreTriggered()) {
store._latestDispatchedActions = getRemainingActions();
store._onActionDispatched = null;
clearTimeout(timeoutID);
resolve();
}
};
});
}
},
/**
* Logs the actions that have been dispatched so far
*/
debug: () => {
if (store._onActionDispatched) {
console.log(
"You have `store.waitForActions(...)` still in progress – have you forgotten to prepend `await` to the method call?",
);
}
console.log(
chalk.bold(
"Dispatched actions since last call of `waitForActions`:\n",
) +
(store._latestDispatchedActions
.map(store._formatDispatchedAction)
.join("\n") || "No dispatched actions") +
chalk.bold(
"\n\nDispatched actions since initialization of test suite:\n",
) +
store._allDispatchedActions
.map(store._formatDispatchedAction)
.join("\n") || "No dispatched actions",
);
},
/**
* Methods for manipulating the simulated browser history
*/
pushPath: path => history.push(path),
goBack: () => history.goBack(),
getPath: () => urlFormat(history.getCurrentLocation()),
warnIfStoreCreationNotComplete: () => {
if (!hasFinishedCreatingStore) {
console.warn(
"Seems that you haven't waited until the store creation has completely finished. " +
"This means that site settings might not have been completely loaded. " +
"Please add `await` in front of createTestStore call.",
);
}
},
/**
* For testing an individual component that is rendered to the router context.
* The component will receive the same router props as it would if it was part of the complete app component tree.
*
* This is usually a lot faster than `getAppContainer` but doesn't work well with react-router links.
*/
connectContainer: reactContainer => {
store.warnIfStoreCreationNotComplete();
const routes = createRoutes(getRoutes(store._finalStoreInstance));
return store._connectWithStore(
<Router
routes={routes}
history={history}
render={props => React.cloneElement(reactContainer, props)}
/>,
);
},
/**
* Renders the whole app tree.
* Useful if you want to navigate between different sections of your app in your tests.
*/
getAppContainer: () => {
store.warnIfStoreCreationNotComplete();
return store._connectWithStore(
<Router history={history}>
{getRoutes(store._finalStoreInstance)}
</Router>,
);
},
/** For having internally access to the store with all middlewares included **/
_setFinalStoreInstance: finalStore => {
store._finalStoreInstance = finalStore;
},
_formatDispatchedAction: action =>
moment(action.timestamp).format("hh:mm:ss.SSS") +
" " +
chalk.cyan(action.type),
// eslint-disable-next-line react/display-name
_connectWithStore: reactContainer => (
<Provider store={store._finalStoreInstance}>{reactContainer}</Provider>
),
};
return Object.assign(store, testStoreExtensions);
};
};
// Commonly used question helpers that are temporarily here
// TODO Atte Keinänen 6/27/17: Put all metabase-lib -related test helpers to one file
export const createSavedQuestion = async unsavedQuestion => {
const savedQuestion = await unsavedQuestion.apiCreate();
savedQuestion._card = {
...savedQuestion.card(),
original_card_id: savedQuestion.id(),
};
return savedQuestion;
};
export const createDashboard = async details => {
let savedDashboard = await DashboardApi.create(details);
return savedDashboard;
};
// useful for tests where multiple users need access to the same questions
export async function createAllUsersWritableCollection() {
const group = _.findWhere(await PermissionsApi.groups(), {
name: "All Users",
});
const collection = await CollectionsApi.create({
name: "test" + Math.random(),
description: "description",
color: "#F1B556",
});
const graph = await CollectionsApi.graph();
graph.groups[group.id][collection.id] = "write";
await CollectionsApi.updateGraph(graph);
return collection;
}
/**
* Waits for a API request with a given method (GET/POST/PUT...) and a url which matches the given regural expression.
* Useful in those relatively rare situations where React components do API requests inline instead of using Redux actions.
*/
export const waitForRequestToComplete = (
method,
urlRegex,
{ timeout = 5000 } = {},
) => {
skippedApiRequests = [];
return new Promise((resolve, reject) => {
const completionTimeoutId = setTimeout(() => {
reject(
new Error(
`API request ${method} ${urlRegex} wasn't completed within ${timeout}ms.\n` +
`Other requests during that time period:\n${skippedApiRequests.join(
"\n",
) || "No requests"}`,
),
);
}, timeout);
apiRequestCompletedCallback = (requestMethod, requestUrl) => {
if (requestMethod === method && urlRegex.test(requestUrl)) {
clearTimeout(completionTimeoutId);
resolve();
} else {
skippedApiRequests.push(`${requestMethod} ${requestUrl}`);
}
};
});
};
export const waitForAllRequestsToComplete = () => {
if (pendingRequests > 0) {
if (!pendingRequestsDeferred) {
pendingRequestsDeferred = defer();
}
return pendingRequestsDeferred.promise;
} else {
return Promise.resolve();
}
};
/**
* Lets you replace given API endpoints with mocked implementations for the lifetime of a test
*/
export async function withApiMocks(mocks, test) {
if (
!mocks.every(
([apiService, endpointName, mockMethod]) =>
_.isObject(apiService) &&
_.isString(endpointName) &&
_.isFunction(mockMethod),
)
) {
throw new Error(
"Seems that you are calling `withApiMocks` with invalid parameters. " +
"The calls should be in format `withApiMocks([[ApiService, endpointName, mockMethod], ...], tests)`.",
);
}
const originals = mocks.map(
([apiService, endpointName]) => apiService[endpointName],
);
// Replace real API endpoints with mocks
mocks.forEach(([apiService, endpointName, mockMethod]) => {
apiService[endpointName] = mockMethod;
});
try {
await test();
} finally {
// Restore original endpoints after tests, even in case of an exception
mocks.forEach(([apiService, endpointName], index) => {
apiService[endpointName] = originals[index];
});
}
}
// async function that tries running an assertion multiple times until it succeeds
// useful for reducing race conditions in tests
// TODO: log API calls and Redux actions that occurred in the meantime
export const eventually = async (assertion, timeout = 5000, period = 250) => {
const start = Date.now();
const errors = [];
const actions = [];
const requests = [];
const addAction = a => actions.push(a);
const addRequest = r => requests.push(r);
events.addListener("action", addAction);
events.addListener("request", addRequest);
const cleanup = () => {
events.removeListener("action", addAction);
events.removeListener("request", addRequest);
};
// eslint-disable-next-line no-constant-condition
while (true) {
try {
await assertion();
if (errors.length > 0) {
console.warn(
"eventually asserted after " + (Date.now() - start) + " ms",
"\n + error:\n",
errors[errors.length - 1],
"\n + actions:\n ",
actions.map(a => a && a.type).join("\n "),
"\n + requests:\n ",
requests.map(r => r && r.url).join("\n "),
);
}
cleanup();
return;
} catch (e) {
if (Date.now() - start >= timeout) {
cleanup();
throw e;
}
errors.push(e);
}
await delay(period);
}
};
// to help tests cleanup after themselves, since integration tests don't use
// isolated environments, e.x.
//
// beforeAll(async () => {
// cleanup.metric(await MetricApi.create({ ... }))
// })
// afterAll(cleanup);
//
export const cleanup = () => {
useSharedAdminLogin();
Promise.all(
cleanup.actions.splice(0, cleanup.actions.length).map(action => action()),
);
};
cleanup.actions = [];
cleanup.fn = action => cleanup.actions.push(action);
cleanup.metric = metric => cleanup.fn(() => deleteMetric(metric));
cleanup.segment = segment => cleanup.fn(() => deleteSegment(segment));
cleanup.question = question => cleanup.fn(() => deleteQuestion(question));
export const deleteQuestion = question =>
CardApi.delete({ cardId: getId(question) });
export const deleteSegment = segment =>
SegmentApi.delete({ segmentId: getId(segment), revision_message: "Please" });
export const deleteMetric = metric =>
MetricApi.delete({ metricId: getId(metric), revision_message: "Please" });
const getId = o =>
typeof o === "object" && o != null
? typeof o.id === "function" ? o.id() : o.id
: o;
export const deleteAllSegments = async () =>
Promise.all((await SegmentApi.list()).map(deleteSegment));
export const deleteAllMetrics = async () =>
Promise.all((await MetricApi.list()).map(deleteMetric));
let pendingRequests = 0;
let pendingRequestsDeferred = null;
// Patches the metabase/lib/api module so that all API queries contain the login credential cookie.
// Needed because we are not in a real web browser environment.
api._makeRequest = async (method, url, headers, requestBody, data, options) => {
pendingRequests++;
try {
const headersWithSessionCookie = {
...headers,
...(loginSession
? { Cookie: `${METABASE_SESSION_COOKIE}=${loginSession.id}` }
: {}),
};
const fetchOptions = {
credentials: "include",
method,
headers: new Headers(headersWithSessionCookie),
...(requestBody ? { body: requestBody } : {}),
};
let isCancelled = false;
if (options.cancelled) {
options.cancelled.then(() => {
isCancelled = true;
});
}
const result = simulateOfflineMode
? { status: 0, responseText: "" }
: await fetch(api.basename + url, fetchOptions);
if (isCancelled) {
throw { status: 0, data: "", isCancelled: true };
}
let resultBody = null;
try {
resultBody = await result.text();
// Even if the result conversion to JSON fails, we still return the original text
// This is 1-to-1 with the real _makeRequest implementation
resultBody = JSON.parse(resultBody);
} catch (e) {}
apiRequestCompletedCallback &&
setTimeout(() => apiRequestCompletedCallback(method, url), 0);
events.emit("request", { method, url });
if (result.status >= 200 && result.status <= 299) {
if (options.transformResponse) {
return options.transformResponse(resultBody, { data });
} else {
return resultBody;
}
} else {
const error = {
status: result.status,
data: resultBody,
isCancelled: false,
};
if (!simulateOfflineMode) {
console.log(
"A request made in a test failed with the following error:",
);
console.log(error, { depth: null });
console.log(`The original request: ${method} ${url}`);
if (requestBody) console.log(`Original payload: ${requestBody}`);
}
throw error;
}
} finally {
pendingRequests--;
if (pendingRequests === 0 && pendingRequestsDeferred) {
process.nextTick(pendingRequestsDeferred.resolve);
pendingRequestsDeferred = null;
}
}
};
// Set the correct base url to metabase/lib/api module
if (
process.env.TEST_FIXTURE_BACKEND_HOST &&
process.env.TEST_FIXTURE_BACKEND_HOST
) {
// Default to the test db fixture
api.basename = process.env.TEST_FIXTURE_BACKEND_HOST;
} else {
console.log(
"Please use `yarn run test-integrated` or `yarn run test-integrated-watch` for running integration tests.",
);
process.quit(0);
}
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;