diff --git a/bin/ci b/bin/ci index 8abd54cb18186127df752bbadd519c8468f7257a..7b47bf54b4097a0999d35df25f09aef90313e874 100755 --- a/bin/ci +++ b/bin/ci @@ -48,8 +48,9 @@ node-4() { node-5() { run_step lein eastwood run_step yarn run lint - run_step yarn run test run_step yarn run flow + run_step yarn run test-unit + run_step yarn run test-karma } node-6() { run_step ./bin/build version sample-dataset uberjar diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 91130dbf3ab4d75844294f83a1b1cc86b8e33ead..0353bc84cdea1c7cecafbb11b9a57e592f87da3e 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -106,33 +106,108 @@ There is also an option to reload changes on save without hot reloading if you p $ yarn run build-watch ``` -#### Unit Tests / Linting +### Frontend testing -Run unit tests with +All frontend tests are located in `frontend/test` directory. Run all frontend tests with - yarn run jest # Jest - yarn run test # Karma +``` +yarn run test +``` -Run the linters and type checker with +### Jest integration tests +Integration tests simulate realistic sequences of user interactions. They render a complete DOM tree using [Enzyme](http://airbnb.io/enzyme/docs/api/index.html) and use temporary backend instances for executing API calls. - yarn run lint - yarn run flow +Integration tests use an enforced file naming convention `<test-suite-name>.integ.js` to separate them from unit tests. -#### End-to-end tests +Useful commands: +```bash +./bin/build version uberjar # Builds the JAR without frontend assets; run this every time you need to update the backend +yarn run test-integrated-watch # Watches for file changes and runs the tests that have changed +yarn run test-integrated-watch -- TestFileName # Watches the files in paths that match the given (regex) string +``` -End-to-end tests are written with [webschauffeur](https://github.com/metabase/webchauffeur) which is a wrapper around [`selenium-webdriver`](https://www.npmjs.com/package/selenium-webdriver). +The way integration tests are written is a little unconventional so here is an example that hopefully helps in getting up to speed: -Generate the Metabase jar file which is used in E2E tests: +``` +import { + login, + createTestStore, +} from "__support__/integrated_tests"; +import { + click +} from "__support__/enzyme_utils" + +import { mount } from "enzyme" + +import { FETCH_DATABASES } from "metabase/redux/metadata"; +import { INITIALIZE_QB } from "metabase/query_builder/actions"; +import RunButton from "metabase/query_builder/components/RunButton"; + +describe("Query builder", () => { + beforeAll(async () => { + // Usually you want to test stuff where user is already logged in + // so it is convenient to login before any test case. + // Remember `await` here! + await login() + }) + + it("should let you run a new query", async () => { + // Create a superpowered Redux store. + // Remember `await` here! + const store = await createTestStore() + + // Go to a desired path in the app. This is safest to do before mounting the app. + store.pushPath('/question') + + // Get React container for the whole app and mount it using Enzyme + const app = mount(store.getAppContainer()) + + // Usually you want to wait until the page has completely loaded, and our way to do that is to + // wait until the completion of specified Redux actions. `waitForActions` is also useful for verifying that + // specific operations are properly executed after user interactions. + // Remember `await` here! + await store.waitForActions([FETCH_DATABASES, INITIALIZE_QB]) + + // You can use `enzymeWrapper.debug()` to see what is the state of DOM tree at the moment + console.log(app.debug()) + + // You can use `testStore.debug()` method to see which Redux actions have been dispatched so far. + // Note that as opposed to Enzyme's debugging method, you don't need to wrap the call to `console.log()`. + store.debug(); + + // For simulating user interactions like clicks and input events you should use methods defined + // in `enzyme_utils.js` as they abstract away some React/Redux complexities. + click(app.find(RunButton)) + + // Note: In pretty rare cases where rendering the whole app is problematic or slow, you can just render a single + // React container instead with `testStore.connectContainer(container)`. In that case you are not able + // to click links that lead to other router paths. + }); +}) - ./bin/build +``` -Run E2E tests once with +You can also skim through [`__support__/integrated_tests.js`](https://github.com/metabase/metabase/blob/master/frontend/test/__support__/integrated_tests.js) and [`__support__/enzyme_utils.js`](https://github.com/metabase/metabase/blob/master/frontend/test/__support__/enzyme_utils.js) to see all available methods. - yarn run test-e2e -or use a persistent browser session with +### Jest unit tests - yarn run test-e2e-dev +Unit tests are focused around isolated parts of business logic. + +Integration tests use an enforced file naming convention `<test-suite-name>.unit.js` to separate them from integration tests. + +``` +yarn run jest-test # Run all tests at once +yarn run jest-test-watch # Watch for file changes +``` + +### Karma browser tests +If you need to test code which uses browser APIs that are only available in real browsers, you can add a Karma test to `frontend/test/legacy-karma` directory. + +``` +yarn run test-karma # Run all tests once +yarn run test-karma-watch # Watch for file changes +``` ## Backend development Leiningen and your REPL are the main development tools for the backend. There are some directions below on how to setup your REPL for easier development. diff --git a/frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx b/frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx index fe5c891c2440bd830c32a74026910987a74f3492..1123ee7ac37e0a1052ab41fcfca9ae42fd2ef714 100644 --- a/frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx @@ -618,7 +618,7 @@ export class FieldRemapping extends Component { } } -const RemappingNamingTip = () => +export const RemappingNamingTip = () => <div className="bordered rounded p1 mt1 mb2 border-brand"> <span className="text-brand text-bold">Tip:</span> You might want to update the field name to make sure it still makes sense based on your remapping choices. </div> diff --git a/frontend/src/metabase/components/Popover.jsx b/frontend/src/metabase/components/Popover.jsx index 2ccacd534527ef6fcf4909c6047c68f1635d660c..fa42dcda7d95080ab84d3365e1c1192ec4d6b612 100644 --- a/frontend/src/metabase/components/Popover.jsx +++ b/frontend/src/metabase/components/Popover.jsx @@ -281,18 +281,20 @@ export default class Popover extends Component { */ export const TestPopover = (props) => (props.isOpen === undefined || props.isOpen) ? - <div - id={props.id} - className={cx("TestPopover TestPopoverBody", props.className)} - style={props.style} - // because popover is normally directly attached to body element, other elements should not need - // to care about clicks that happen inside the popover - onClick={ (e) => { e.stopPropagation(); } } + <OnClickOutsideWrapper + handleDismissal={(...args) => { props.onClose && props.onClose(...args) }} + dismissOnEscape={props.dismissOnEscape} + dismissOnClickOutside={props.dismissOnClickOutside} > - { typeof props.children === "function" ? - props.children() - : - props.children - } - </div> + <div + id={props.id} + className={cx("TestPopover TestPopoverBody", props.className)} + style={props.style} + // because popover is normally directly attached to body element, other elements should not need + // to care about clicks that happen inside the popover + onClick={ (e) => { e.stopPropagation(); } } + > + { typeof props.children === "function" ? props.children() : props.children} + </div> + </OnClickOutsideWrapper> : null diff --git a/frontend/test/legacy-selenium/support/backend.js b/frontend/test/__runner__/backend.js similarity index 67% rename from frontend/test/legacy-selenium/support/backend.js rename to frontend/test/__runner__/backend.js index 3552992cf341f6713e186158f838e31503dac040..a2a2e3ccf86c9d8833e7ef38be4ece2eac020e06 100644 --- a/frontend/test/legacy-selenium/support/backend.js +++ b/frontend/test/__runner__/backend.js @@ -4,11 +4,9 @@ import path from "path"; import { spawn } from "child_process"; import fetch from 'isomorphic-fetch'; -import { delay } from '../../../src/metabase/lib/promise'; +import { delay } from '../../src/metabase/lib/promise'; -import createSharedResource from "./shared-resource"; - -export const DEFAULT_DB = "frontend/test/legacy-selenium/support/fixtures/metabase.db"; +export const DEFAULT_DB = __dirname + "/test_db_fixture.db"; let testDbId = 0; const getDbFile = () => path.join(os.tmpdir(), `metabase-test-${process.pid}-${testDbId++}.db`); @@ -74,6 +72,7 @@ export const BackendResource = createSharedResource("BackendResource", { async stop(server) { if (server.process) { server.process.kill('SIGKILL'); + console.log("Stopped backend (host=" + server.host + " dbKey=" + server.dbKey + ")"); } try { if (server.dbFile) { @@ -94,3 +93,54 @@ export async function isReady(host) { } return false; } + +function createSharedResource(resourceName, { + defaultOptions, + getKey = (options) => JSON.stringify(options), + create = (options) => ({}), + start = (resource) => {}, + stop = (resource) => {}, +}) { + let entriesByKey = new Map(); + let entriesByResource = new Map(); + + function kill(entry) { + if (entriesByKey.has(entry.key)) { + entriesByKey.delete(entry.key); + entriesByResource.delete(entry.resource); + let p = stop(entry.resource).then(null, (err) => + console.log("Error stopping resource", resourceName, entry.key, err) + ); + return p; + } + } + + return { + get(options = defaultOptions) { + let key = getKey(options); + let entry = entriesByKey.get(key); + if (!entry) { + entry = { + key: key, + references: 0, + resource: create(options) + } + entriesByKey.set(entry.key, entry); + entriesByResource.set(entry.resource, entry); + } else { + } + ++entry.references; + return entry.resource; + }, + async start(resource) { + let entry = entriesByResource.get(resource); + return start(entry.resource); + }, + async stop(resource) { + let entry = entriesByResource.get(resource); + if (entry && --entry.references <= 0) { + await kill(entry); + } + } + } +} \ No newline at end of file diff --git a/frontend/test/run-integrated-tests.js b/frontend/test/__runner__/run_integrated_tests.js similarity index 51% rename from frontend/test/run-integrated-tests.js rename to frontend/test/__runner__/run_integrated_tests.js index 773196d7b025b42fc81713434da4f95d47bb90b3..ddaf6ee356e2187a08694c93a8a7ca8552e84c9b 100755 --- a/frontend/test/run-integrated-tests.js +++ b/frontend/test/__runner__/run_integrated_tests.js @@ -3,16 +3,32 @@ let jasmineAfterAllCleanup = async () => {} global.afterAll = (method) => { jasmineAfterAllCleanup = method; } import { spawn } from "child_process"; +import fs from "fs"; +import chalk from "chalk"; -// use require for BackendResource to run it after the mock afterAll has been set -const BackendResource = require("./legacy-selenium/support/backend.js").BackendResource +// Use require for BackendResource to run it after the mock afterAll has been set +const BackendResource = require("./backend.js").BackendResource +// Backend that uses a test fixture database +// If you need to update the fixture, you can run Metabase with `MB_DB_TYPE=h2 MB_DB_FILE=frontend/test/legacy-selenium/support/fixtures/metabase.db` const serverWithTestDbFixture = BackendResource.get({}); const testFixtureBackendHost = serverWithTestDbFixture.host; const serverWithPlainDb = BackendResource.get({ dbKey: "" }); const plainBackendHost = serverWithPlainDb.host; +const userArgs = process.argv.slice(2); +const isJestWatchMode = userArgs[0] === "--watch" + +function readFile(fileName) { + return new Promise(function(resolve, reject){ + fs.readFile(fileName, 'utf8', (err, data) => { + if (err) { reject(err); } + resolve(data); + }) + }); +} + const login = async (apiHost) => { const loginFetchOptions = { method: "POST", @@ -42,18 +58,37 @@ const login = async (apiHost) => { } const init = async() => { + if (!isJestWatchMode) { + console.log(chalk.yellow('If you are developing locally, prefer using `lein run test-integrated-watch` instead.\n')); + } + + try { + const version = await readFile(__dirname + "/../../../resources/version.properties") + console.log(chalk.bold('Running integrated test runner with this build:')); + process.stdout.write(chalk.cyan(version)) + console.log(chalk.bold('If that version seems too old, please run `./bin/build version uberjar`.\n')); + } catch(e) { + console.log(chalk.bold('No version file found. Please run `./bin/build version uberjar`.')); + process.exit(1) + } + + console.log(chalk.bold('1/4 Starting first backend with test H2 database fixture')); + console.log(chalk.cyan('You can update the fixture by running a local instance against it:\n`MB_DB_TYPE=h2 MB_DB_FILE=frontend/test/__runner__/test_db_fixture.db lein run`')) await BackendResource.start(serverWithTestDbFixture) + console.log(chalk.bold('2/4 Starting second backend with plain database')); await BackendResource.start(serverWithPlainDb) + console.log(chalk.bold('3/4 Creating a shared login session for backend 1')); const sharedLoginSession = await login(testFixtureBackendHost) + console.log(chalk.bold('4/4 Starting Jest')); const env = { ...process.env, "TEST_FIXTURE_BACKEND_HOST": testFixtureBackendHost, "PLAIN_BACKEND_HOST": plainBackendHost, "TEST_FIXTURE_SHARED_LOGIN_SESSION_ID": sharedLoginSession.id } - const userArgs = process.argv.slice(2); + const jestProcess = spawn( "yarn", ["run", "jest", "--", "--maxWorkers=1", "--config", "jest.integ.conf.json", ...userArgs], @@ -69,19 +104,33 @@ const init = async() => { } const cleanup = async (exitCode = 0) => { + console.log(chalk.bold('Cleaning up...')) await jasmineAfterAllCleanup(); await BackendResource.stop(serverWithTestDbFixture); await BackendResource.stop(serverWithPlainDb); process.exit(exitCode); + } -init() - .then(cleanup) - .catch((e) => { - console.error(e); - cleanup(1); - }); +const askWhetherToQuit = (exitCode) => { + console.log(chalk.bold('Jest process exited. Press [ctrl-c] to quit the integrated test runner or any other key to restart Jest.')); + process.stdin.once('data', launch); +} + +const launch = () => + init() + .then(isJestWatchMode ? askWhetherToQuit : cleanup) + .catch((e) => { + console.error(e); + cleanup(1); + }) + +launch() process.on('SIGTERM', () => { cleanup(); +}) + +process.on('SIGINT', () => { + cleanup() }) \ No newline at end of file diff --git a/frontend/test/legacy-selenium/support/fixtures/metabase.db.h2.db b/frontend/test/__runner__/test_db_fixture.db.h2.db similarity index 99% rename from frontend/test/legacy-selenium/support/fixtures/metabase.db.h2.db rename to frontend/test/__runner__/test_db_fixture.db.h2.db index 4b8935d9b1ad169a7870fe7e5f12d9a34b12a70c..5f280a61cd1dca837684b1b2622df48bce1182ba 100644 Binary files a/frontend/test/legacy-selenium/support/fixtures/metabase.db.h2.db and b/frontend/test/__runner__/test_db_fixture.db.h2.db differ diff --git a/frontend/test/__support__/enzyme_utils.js b/frontend/test/__support__/enzyme_utils.js index 3a18c71e000e00f4b3b0448411c2786f39e0b0fd..edb5433fa1cf724685b02c1762e4d4f3cdc77fba 100644 --- a/frontend/test/__support__/enzyme_utils.js +++ b/frontend/test/__support__/enzyme_utils.js @@ -1,5 +1,20 @@ +// This must be before all other imports +import { eventListeners } from "./mocks"; + import Button from "metabase/components/Button"; +// Triggers events that are being listened to with `window.addEventListener` or `document.addEventListener` +export const dispatchBrowserEvent = (eventName, ...args) => { + if (eventListeners[eventName]) { + eventListeners[eventName].forEach(listener => listener(...args)) + } else { + throw new Error( + `No event listeners are currently attached to event '${eventName}'. List of event listeners:\n` + + Object.entries(eventListeners).map(([name, funcs]) => `${name} (${funcs.length} listeners)`).join('\n') + ) + } +} + export const click = (enzymeWrapper) => { if (enzymeWrapper.length === 0) { throw new Error("The wrapper you provided for `click(wrapper)` is empty.") diff --git a/frontend/test/__support__/mocks.js b/frontend/test/__support__/mocks.js index cfed14bb4923760d66bb0b9060fc38118d5b66dd..a519aa5c8d6c52d232c1bcc3db551d3549af8c02 100644 --- a/frontend/test/__support__/mocks.js +++ b/frontend/test/__support__/mocks.js @@ -4,8 +4,10 @@ global.ace.require = () => {} global.window.matchMedia = () => ({ addListener: () => {}, removeListener: () => {} }) +// Disable analytics jest.mock('metabase/lib/analytics'); +// Suppress ace import errors jest.mock("ace/ace", () => {}, {virtual: true}); jest.mock("ace/mode-plain_text", () => {}, {virtual: true}); jest.mock("ace/mode-javascript", () => {}, {virtual: true}); @@ -26,6 +28,7 @@ jest.mock("ace/snippets/json", () => {}, {virtual: true}); jest.mock("ace/snippets/json", () => {}, {virtual: true}); jest.mock("ace/ext-language_tools", () => {}, {virtual: true}); +// Use test versions of components that are normally rendered to document root or use unsupported browser APIs import * as modal from "metabase/components/Modal"; modal.default = modal.TestModal; @@ -41,3 +44,16 @@ bodyComponent.default = bodyComponent.TestBodyComponent import * as table from "metabase/visualizations/visualizations/Table"; table.default = table.TestTable +// Replace addEventListener with a test implementation which collects all event listeners to `eventListeners` map +export let eventListeners = {}; +const testAddEventListener = jest.fn((event, listener) => { + eventListeners[event] = eventListeners[event] ? [...eventListeners[event], listener] : [listener] +}) +const testRemoveEventListener = jest.fn((event, listener) => { + eventListeners[event] = (eventListeners[event] || []).filter(l => l !== listener) +}) + +global.document.addEventListener = testAddEventListener +global.window.addEventListener = testAddEventListener +global.document.removeEventListener = testRemoveEventListener +global.window.removeEventListener = testRemoveEventListener diff --git a/frontend/test/admin/datamodel/FieldApp.integ.spec.js b/frontend/test/admin/datamodel/FieldApp.integ.spec.js index f43cd62ef9fc2c4dd5de70a6590a7e51e623fae9..cb4b6e6cfd84ca0eb771f0a9f01b8cfc50f73dd5 100644 --- a/frontend/test/admin/datamodel/FieldApp.integ.spec.js +++ b/frontend/test/admin/datamodel/FieldApp.integ.spec.js @@ -6,7 +6,7 @@ import { import { clickButton, setInputValue, - click + click, dispatchBrowserEvent } from "__support__/enzyme_utils" import { DELETE_FIELD_DIMENSION, @@ -27,7 +27,10 @@ import { mount } from "enzyme"; import { FETCH_IDFIELDS } from "metabase/admin/datamodel/datamodel"; import { delay } from "metabase/lib/promise" import FieldApp, { - FieldHeader, FieldRemapping, FieldValueMapping, + FieldHeader, + FieldRemapping, + FieldValueMapping, + RemappingNamingTip, ValueRemappings } from "metabase/admin/datamodel/containers/FieldApp"; import Input from "metabase/components/Input"; @@ -39,6 +42,7 @@ import { TestPopover } from "metabase/components/Popover"; import Select from "metabase/components/Select"; import SelectButton from "metabase/components/SelectButton"; import ButtonWithStatus from "metabase/components/ButtonWithStatus"; +import { getMetadata } from "metabase/selectors/metadata"; const getRawFieldWithId = (store, fieldId) => store.getState().metadata.fields[fieldId]; @@ -301,6 +305,36 @@ describe("FieldApp", () => { store.waitForActions([DELETE_FIELD_DIMENSION, FETCH_TABLE_METADATA]); }) + it("forces you to choose the FK field manually if there is no field with Field Name special type", async () => { + const { store, fieldApp } = await initFieldApp({ fieldId: USER_ID_FK_ID }); + + // Set FK id to `Reviews -> ID` with a direct metadata update call + const field = getMetadata(store.getState()).fields[USER_ID_FK_ID] + await store.dispatch(updateField({ + ...field.getPlainObject(), + fk_target_field_id: 31 + })); + + const section = fieldApp.find(FieldRemapping) + const mappingTypePicker = section.find(Select); + expect(mappingTypePicker.text()).toBe('Use original value') + click(mappingTypePicker); + const pickerOptions = mappingTypePicker.find(TestPopover).find("li"); + expect(pickerOptions.length).toBe(2); + + const useFKButton = pickerOptions.at(1).children().first() + click(useFKButton); + store.waitForActions([UPDATE_FIELD_DIMENSION, FETCH_TABLE_METADATA]) + // TODO: Figure out a way to avoid using delay – the use of delays may lead to occasional CI failures + await delay(500); + + expect(section.find(RemappingNamingTip).length).toBe(1) + + dispatchBrowserEvent('mousedown', { e: { target: document.documentElement }}) + await delay(10); // delay needed because of setState in FieldApp + expect(section.find(".text-danger").length).toBe(1) // warning that you should choose a column + }) + it("doesn't let you enter custom remappings for a field with string values", async () => { const { fieldApp } = await initFieldApp({ tableId: USER_SOURCE_TABLE_ID, fieldId: USER_SOURCE_ID }); const section = fieldApp.find(FieldRemapping) @@ -365,6 +399,13 @@ describe("FieldApp", () => { afterAll(async () => { const store = await createTestStore() + await store.dispatch(fetchTableMetadata(1)) + + const field = getMetadata(store.getState()).fields[USER_ID_FK_ID] + await store.dispatch(updateField({ + ...field.getPlainObject(), + fk_target_field_id: 13 // People -> ID + })); await store.dispatch(deleteFieldDimension(USER_ID_FK_ID)); await store.dispatch(deleteFieldDimension(PRODUCT_RATING_ID)); diff --git a/frontend/test/e2e-with-persistent-browser.js b/frontend/test/e2e-with-persistent-browser.js deleted file mode 100755 index 07441d5380fbc38961ce30630944e7ef252dc335..0000000000000000000000000000000000000000 --- a/frontend/test/e2e-with-persistent-browser.js +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node - -const exec = require('child_process').exec -const execSync = require('child_process').execSync -const fs = require('fs'); -const webdriver = require('selenium-webdriver'); - -// User input initialization -const stdin = fs.openSync('/dev/stdin', 'rs'); -const buffer = Buffer.alloc(8); - -// Yarn must be executed from project root -process.chdir(__dirname + '/../..'); - -const url = 'http://localhost:9515'; -const driverProcess = exec('chromedriver --port=9515'); - -const driver = new webdriver.Builder() - .forBrowser('chrome') - .usingServer(url) - .build(); - -driver.getSession().then(function (session) { - const id = session.getId() - console.log('Launched persistent Webdriver session with session ID ' + id, url); - - function executeTest() { - const hasCommandToExecuteBeforeReload = - process.argv.length >= 4 && process.argv[2] === '--exec-before' - - if (hasCommandToExecuteBeforeReload) { - console.log(execSync(process.argv[3]).toString()) - } - - const cmd = 'WEBDRIVER_SESSION_ID=' + id + ' WEBDRIVER_SESSION_URL=' + url + ' yarn run test-e2e'; - console.log(cmd); - - const testProcess = exec(cmd); - testProcess.stdout.pipe(process.stdout); - testProcess.stderr.pipe(process.stderr); - testProcess.on('exit', function () { - console.log("Press <Enter> to rerun tests or <C-c> to quit.") - fs.readSync(stdin, buffer, 0, 8); - executeTest(); - }) - } - - executeTest(); -}); - -process.on('SIGTERM', function () { - console.log('Shutting down...') - driver.quit().then(function () { - process.exit(0) - }); - driverProcess.kill('SIGINT'); -}); diff --git a/frontend/test/legacy-selenium/.eslintrc b/frontend/test/legacy-selenium/.eslintrc deleted file mode 100644 index 268deef82ecbb9e230d491c072df1fd992b42ec7..0000000000000000000000000000000000000000 --- a/frontend/test/legacy-selenium/.eslintrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "env": { - "jasmine": true, - "node": true - }, - "globals": { - "d": true, - "driver": true, - "server": true - } -} diff --git a/frontend/test/legacy-selenium/auth/login.spec.js b/frontend/test/legacy-selenium/auth/login.spec.js index 1edd08d08609ee49083e2ce7ed56fcf4f80f1ac5..df4f699274f434175930d3257adf772efaef66b6 100644 --- a/frontend/test/legacy-selenium/auth/login.spec.js +++ b/frontend/test/legacy-selenium/auth/login.spec.js @@ -1,3 +1,4 @@ +/* eslint-disable */ // NOTE Atte Keinänen 9/8/17: This can't be converted to Jest as NodeJS doesn't have cookie support // Probably we just want to remove this test. diff --git a/frontend/test/legacy-selenium/dashboard/dashboard.utils.js b/frontend/test/legacy-selenium/dashboard/dashboard.utils.js deleted file mode 100644 index 7ff3e98379884df4ff0ac6c2b5c753c274bdc238..0000000000000000000000000000000000000000 --- a/frontend/test/legacy-selenium/dashboard/dashboard.utils.js +++ /dev/null @@ -1,5 +0,0 @@ -export const removeCurrentDash = async () => { - await d.select(".Icon.Icon-pencil").wait().click(); - await d.select(".EditHeader .flex-align-right a:nth-of-type(2)").wait().click(); - await d.select(".Button.Button--danger").wait().click(); -} diff --git a/frontend/test/legacy-selenium/dashboards/dashboards.spec.js b/frontend/test/legacy-selenium/dashboards/dashboards.spec.js index 58d26e4055f012c928ae9436f3182da1902bc9a1..673cd3870032a4bbcd61c7d8412aa387df864ad9 100644 --- a/frontend/test/legacy-selenium/dashboards/dashboards.spec.js +++ b/frontend/test/legacy-selenium/dashboards/dashboards.spec.js @@ -1,3 +1,5 @@ +/* eslint-disable */ +// NOTE Atte Keinänen 28/8/17: Should be converted to Jest/Enzyme, should be pretty straight-forward. import { ensureLoggedIn, describeE2E @@ -5,9 +7,8 @@ import { import { createDashboardInEmptyState, getLatestDashboardUrl, getPreviousDashboardUrl, - incrementDashboardCount + incrementDashboardCount, removeCurrentDash } from "./dashboards.utils" -import {removeCurrentDash} from "../dashboard/dashboard.utils" jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000; diff --git a/frontend/test/legacy-selenium/dashboards/dashboards.utils.js b/frontend/test/legacy-selenium/dashboards/dashboards.utils.js index f5e6a1020258bdaf018b8d2473700d367ed5baad..726e4072fb979f9ea8ed764263920f8ca41fece8 100644 --- a/frontend/test/legacy-selenium/dashboards/dashboards.utils.js +++ b/frontend/test/legacy-selenium/dashboards/dashboards.utils.js @@ -1,3 +1,4 @@ +/* eslint-disable */ export var dashboardCount = 0 export const incrementDashboardCount = () => { dashboardCount += 1; @@ -22,3 +23,9 @@ export const createDashboardInEmptyState = async () => { await d.waitUrl(getLatestDashboardUrl()); } + +export const removeCurrentDash = async () => { + await d.select(".Icon.Icon-pencil").wait().click(); + await d.select(".EditHeader .flex-align-right a:nth-of-type(2)").wait().click(); + await d.select(".Button.Button--danger").wait().click(); +} \ No newline at end of file diff --git a/frontend/test/legacy-selenium/query_builder/tutorial.spec.js b/frontend/test/legacy-selenium/query_builder/tutorial.spec.js index 5c8aae8c95d29a9bd684bf413f1c60d766de4b81..18e16b317ee4ccdb3039bd38d822ad64512db3d0 100644 --- a/frontend/test/legacy-selenium/query_builder/tutorial.spec.js +++ b/frontend/test/legacy-selenium/query_builder/tutorial.spec.js @@ -1,3 +1,9 @@ +/* eslint-disable */ +// NOTE Atte Keinänen 28/8/17: This should be converted to Jest/Enzyme. I will be tricky because tutorial involves +// lots of direct DOM manipulation. See also "Ability to dismiss popovers, modals etc" in +// https://github.com/metabase/metabase/issues/5527 + + import { waitForElement, waitForElementRemoved, diff --git a/frontend/test/legacy-selenium/support/jasmine.js b/frontend/test/legacy-selenium/support/jasmine.js deleted file mode 100644 index e32bc59d501041615bae0e524b986937fa28c5b0..0000000000000000000000000000000000000000 --- a/frontend/test/legacy-selenium/support/jasmine.js +++ /dev/null @@ -1,32 +0,0 @@ -// https://github.com/matthewjh/jasmine-promises/issues/3 -if (!global.jasmineRequire) { - // jasmine 2 and jasmine promises have differing ideas on what to do inside protractor/node - var jasmineRequire = require('jasmine-core'); - if (typeof jasmineRequire.interface !== 'function') { - throw "not able to load real jasmineRequire" - } - global.jasmineRequire = jasmineRequire; -} - -require('jasmine-promises'); - -// Console spec reporter -import { SpecReporter } from "jasmine-spec-reporter"; -jasmine.getEnv().addReporter(new SpecReporter()); - -// JUnit XML reporter for CircleCI -import { JUnitXmlReporter } from "jasmine-reporters"; -jasmine.getEnv().addReporter(new JUnitXmlReporter({ - savePath: (process.env["CIRCLE_TEST_REPORTS"] || ".") + "/test-report-e2e", - consolidateAll: false -})); - -// HACK to enable jasmine.getEnv().currentSpec -jasmine.getEnv().addReporter({ - specStarted(result) { - jasmine.getEnv().currentSpecResult = result; - }, - specDone() { - jasmine.getEnv().currentSpecResult = null; - } -}); diff --git a/frontend/test/legacy-selenium/support/jasmine.json b/frontend/test/legacy-selenium/support/jasmine.json deleted file mode 100644 index 1c89e352a78c6b3baf5e024ce1cacf8844cfcd9b..0000000000000000000000000000000000000000 --- a/frontend/test/legacy-selenium/support/jasmine.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "spec_dir": "frontend/test/e2e", - "spec_files": [ - "**/*[sS]pec.js" - ], - "helpers": [ - "../../../node_modules/babel-register/lib/node.js", - "../../../node_modules/babel-polyfill/lib/index.js", - "./support/jasmine.js" - ], - "stopSpecOnExpectationFailure": false, - "random": false -} diff --git a/frontend/test/legacy-selenium/support/metabase.js b/frontend/test/legacy-selenium/support/metabase.js deleted file mode 100644 index 53553043f6c8fa988b7bee867132820613d156ca..0000000000000000000000000000000000000000 --- a/frontend/test/legacy-selenium/support/metabase.js +++ /dev/null @@ -1,43 +0,0 @@ - -export async function logout() { - await this.wd().manage().deleteAllCookies(); - return this; -} - -export async function startGuiQuestion(text) { - await this.get("/question"); - return this; -} - -export async function startNativeQuestion(text) { - await this.get("/question"); - await this.select(".Icon-sql").wait().click(); - await this.select(".ace_text-input").wait().sendKeys(text); - return this; -} - -export async function runQuery() { - await this.select(".RunButton").wait().click(); - return this; -} - -export async function saveQuestion(questionName, newDashboardName) { - // save question - await this.select(".Header-buttonSection:first-child").wait().click(); - await this.select("#SaveQuestionModal input[name='name']").wait().sendKeys(questionName); - await this.select("#SaveQuestionModal .Button.Button--primary").wait().click().waitRemoved(); // wait for the modal to be removed - - if (newDashboardName) { - // add to new dashboard - await this.select("#QuestionSavedModal .Button.Button--primary").wait().click(); - await this.select("#CreateDashboardModal input[name='name']").wait().sendKeys(newDashboardName); - await this.select("#CreateDashboardModal .Button.Button--primary").wait().click().waitRemoved(); // wait for the modal to be removed - } else { - await this.select("#QuestionSavedModal .Button:contains(Not)").wait().click(); - } - - // wait for modal to close :-/ - await this.sleep(500); - - return this; -} diff --git a/frontend/test/legacy-selenium/support/sauce.js b/frontend/test/legacy-selenium/support/sauce.js deleted file mode 100644 index 8aeebcd2abcae615f0b960c029e8e2e8c77e70bc..0000000000000000000000000000000000000000 --- a/frontend/test/legacy-selenium/support/sauce.js +++ /dev/null @@ -1,53 +0,0 @@ -import sauceConnectLauncher from "sauce-connect-launcher"; - -import createSharedResource from "./shared-resource"; - -export const USE_SAUCE = process.env["USE_SAUCE"]; -const SAUCE_USERNAME = process.env["SAUCE_USERNAME"]; -const SAUCE_ACCESS_KEY = process.env["SAUCE_ACCESS_KEY"]; -const CIRCLE_BUILD_NUM = process.env["CIRCLE_BUILD_NUM"]; - -export const sauceServer = `http://${SAUCE_USERNAME}:${SAUCE_ACCESS_KEY}@localhost:4445/wd/hub`; - -export const sauceCapabilities = { - browserName: 'chrome', - version: '52.0', - platform: 'macOS 10.12', - username: SAUCE_USERNAME, - accessKey: SAUCE_ACCESS_KEY, - build: CIRCLE_BUILD_NUM -}; - -export const sauceConnectConfig = { - username: SAUCE_USERNAME, - accessKey: SAUCE_ACCESS_KEY -} - -export const SauceConnectResource = createSharedResource("SauceConnectResource", { - defaultOptions: sauceConnectConfig, - create(options) { - return { - options, - promise: null - }; - }, - async start(sauce) { - if (USE_SAUCE) { - if (!sauce.promise) { - sauce.promise = new Promise((resolve, reject) => { - sauceConnectLauncher(sauce.options, (err, proc) => - err ? reject(err) : resolve(proc) - ); - }); - } - return sauce.promise; - } - }, - async stop(sauce) { - if (sauce.promise) { - let p = sauce.promise; - delete sauce.promise; - return p.then(proc => proc.close()) - } - } -}); diff --git a/frontend/test/legacy-selenium/support/shared-resource.js b/frontend/test/legacy-selenium/support/shared-resource.js deleted file mode 100644 index efb639af73f5b4805c4458c2a7cc24db356ae145..0000000000000000000000000000000000000000 --- a/frontend/test/legacy-selenium/support/shared-resource.js +++ /dev/null @@ -1,65 +0,0 @@ - -const exitHandlers = [] -afterAll(() => { - return Promise.all(exitHandlers.map(handler => handler())); -}) - -export default function createSharedResource(resourceName, { - defaultOptions, - getKey = (options) => JSON.stringify(options), - create = (options) => ({}), - start = (resource) => {}, - stop = (resource) => {}, -}) { - let entriesByKey = new Map(); - let entriesByResource = new Map(); - - let exitPromises = []; - exitHandlers.push(() => { - for (const entry of entriesByKey.values()) { - kill(entry); - } - return Promise.all(exitPromises); - }) - - function kill(entry) { - if (entriesByKey.has(entry.key)) { - entriesByKey.delete(entry.key); - entriesByResource.delete(entry.resource); - let p = stop(entry.resource).then(null, (err) => - console.log("Error stopping resource", resourceName, entry.key, err) - ); - exitPromises.push(p); - return p; - } - } - - return { - get(options = defaultOptions) { - let key = getKey(options); - let entry = entriesByKey.get(key); - if (!entry) { - entry = { - key: key, - references: 0, - resource: create(options) - } - entriesByKey.set(entry.key, entry); - entriesByResource.set(entry.resource, entry); - } else { - } - ++entry.references; - return entry.resource; - }, - async start(resource) { - let entry = entriesByResource.get(resource); - return start(entry.resource); - }, - async stop(resource) { - let entry = entriesByResource.get(resource); - if (entry && --entry.references <= 0) { - await kill(entry); - } - } - } -} diff --git a/frontend/test/legacy-selenium/support/utils.js b/frontend/test/legacy-selenium/support/utils.js deleted file mode 100644 index 89c17fa2f3ab9abd8c5b315d4901ff2e81063b5b..0000000000000000000000000000000000000000 --- a/frontend/test/legacy-selenium/support/utils.js +++ /dev/null @@ -1,304 +0,0 @@ -import fs from "fs-promise"; -import path from "path"; - -import { By, until } from "selenium-webdriver"; - -import { Driver } from "webchauffeur"; - -const DEFAULT_TIMEOUT = 50000; - -// these are sessions persisted in the fixture dbs, to avoid having to login -const DEFAULT_SESSIONS = { - "bob@metabase.com": "068a6678-db09-4853-b7d5-d0ef6cb9cbc8" -} - -const log = (message) => { - console.log(message); -}; - -export const findElement = (driver, selector) => { - // consider looking into a better test reporter - // default jasmine reporter leaves much to be desired - log(`looking for element: ${selector}`); - return driver.findElement(By.css(selector)); -}; - -export const waitForElement = (driver, selector, timeout = DEFAULT_TIMEOUT) => { - log(`waiting for element: ${selector}`); - if (typeof selector === "string") { - selector = By.css(selector); - } - return driver.wait(until.elementLocated(selector), timeout); -}; - -export const waitForElementRemoved = (driver, selector, timeout = DEFAULT_TIMEOUT) => { - log(`waiting for element to be removed: ${selector}`); - return driver.wait(() => - driver.findElements(By.css(selector)).then(elements => elements.length === 0) - , timeout); -}; - -export const waitForElementText = async (driver, selector, expectedText, timeout = DEFAULT_TIMEOUT) => { - if (!expectedText) { - log(`waiting for element text: ${selector}`); - return await waitForElement(driver, selector, timeout).getText(); - } else { - log(`waiting for element text to equal ${expectedText}: ${selector}`); - try { - // Need the wait condition to findElement rather than once at start in case elements are added/removed - await driver.wait(async () => - (await driver.findElement(By.css(selector)).getText()) === expectedText - , timeout); - } catch (e) { - log(`element text for ${selector} was: ${await driver.findElement(By.css(selector)).getText()}`) - throw e; - } - } -}; - -export const clickElement = (driver, selector) => { - log(`clicking on element: ${selector}`) - return findElement(driver, selector).click(); -}; - -// waits for element to appear before clicking to avoid clicking too early -// prefer this over calling click() on element directly -export const waitForElementAndClick = async (driver, selector, timeout = DEFAULT_TIMEOUT) => { - log(`waiting to click: ${selector}`); - let element = await waitForElement(driver, selector, timeout); - - element = await driver.wait(until.elementIsVisible(element), timeout); - element = await driver.wait(until.elementIsEnabled(element), timeout); - - // help with brittleness - await driver.sleep(100); - - return await element.click(); -}; - -export const waitForElementAndSendKeys = async (driver, selector, keys, timeout = DEFAULT_TIMEOUT) => { - log(`waiting for element to send "${keys}": ${selector}`); - const element = await waitForElement(driver, selector, timeout); - await element.clear(); - return await element.sendKeys(keys); -}; - -export const waitForUrl = (driver, url, timeout = DEFAULT_TIMEOUT) => { - log(`waiting for url: ${url}`); - return driver.wait(async () => await driver.getCurrentUrl() === url, timeout); -}; - -const screenshotToHideSelectors = { - "screenshots/setup-tutorial-main.png": [ - "#Greeting" - ], - "screenshots/qb.png": [ - ".LoadingSpinner" - ], - "screenshots/auth-login.png": [ - ".brand-boat" - ] -}; - -export const screenshot = async (driver, filename) => { - log(`taking screenshot: ${filename}`); - const dir = path.dirname(filename); - if (dir && !(await fs.exists(dir))){ - await fs.mkdir(dir); - } - - // hide elements that are always in motion or randomized - const hideSelectors = screenshotToHideSelectors[filename]; - if (hideSelectors) { - await hideSelectors.map((selector) => driver.executeScript(` - const element = document.querySelector("${selector}"); - if (!element) { - return; - } - element.classList.add('hide'); - `)); - } - - // blur input focus to avoid capturing blinking cursor in diffs - await driver.executeScript(`document.activeElement.blur();`); - - const image = await driver.takeScreenshot(); - await fs.writeFile(filename, image, 'base64'); -}; - -export const screenshotFailures = async (driver) => { - let result = jasmine.getEnv().currentSpecResult; - if (result && result.failedExpectations.length > 0) { - await screenshot(driver, "screenshots/failures/" + result.fullName.toLowerCase().replace(/[^a-z0-9_]/g, "_")); - } -} - -export const getJson = async (driver, url) => { - await driver.get(url); - try { - let source = await driver.findElement(By.tagName("body")).getText(); - return JSON.parse(source); - } catch (e) { - return null; - } -} - -export const checkLoggedIn = async (server, driver, email) => { - let currentUser = await getJson(driver, `${server.host}/api/user/current`); - return currentUser && currentUser.email === email; -} - -const getSessionId = (server, email) => { - server.sessions = server.sessions || { ...DEFAULT_SESSIONS }; - return server.sessions[email]; -} -const setSessionId = (server, email, sessionId) => { - server.sessions = server.sessions || { ...DEFAULT_SESSIONS }; - server.sessions[email] = sessionId; -} - -export const ensureLoggedIn = async (server, driver, email, password) => { - if (await checkLoggedIn(server, driver, email)) { - console.log("LOGIN: already logged in"); - return; - } - const sessionId = getSessionId(server, email); - if (sessionId != null) { - console.log("LOGIN: trying previous session"); - await driver.get(`${server.host}/`); - await driver.manage().deleteAllCookies(); - await driver.manage().addCookie("metabase.SESSION_ID", sessionId); - await driver.get(`${server.host}/`); - if (await checkLoggedIn(server, driver, email)) { - console.log("LOGIN: cached session succeeded"); - return; - } else { - console.log("LOGIN: cached session failed"); - setSessionId(server, email, null); - } - } - - console.log("LOGIN: logging in manually"); - await driver.get(`${server.host}/`); - await driver.manage().deleteAllCookies(); - await driver.get(`${server.host}/`); - await loginMetabase(driver, email, password); - await waitForUrl(driver, `${server.host}/`); - - const sessionCookie = await driver.manage().getCookie("metabase.SESSION_ID"); - setSessionId(server, email, sessionCookie.value); -} - -export const loginMetabase = async (driver, email, password) => { - await driver.wait(until.elementLocated(By.css("[name=email]"))); - await driver.findElement(By.css("[name=email]")).sendKeys(email); - await driver.findElement(By.css("[name=password]")).sendKeys(password); - await driver.manage().timeouts().implicitlyWait(1000); - await driver.findElement(By.css(".Button.Button--primary")).click(); -}; - -import { BackendResource, isReady } from "./backend"; -import { WebdriverResource } from "./webdriver"; -import { SauceConnectResource } from "./sauce"; - -export const describeE2E = (name, options, describeCallback) => { - if (typeof options === "function") { - describeCallback = options; - options = {}; - } - - options = { name, ...options }; - - let server = BackendResource.get({ dbKey: options.dbKey }); - let webdriver = WebdriverResource.get(); - let sauce = SauceConnectResource.get(); - - describe(name, jasmineMultipleSetupTeardown(() => { - beforeAll(async () => { - await Promise.all([ - BackendResource.start(server), - SauceConnectResource.start(sauce).then(()=> - WebdriverResource.start(webdriver)), - ]); - - global.driver = webdriver.driver; - global.d = new Driver(webdriver.driver, { - base: server.host - }); - global.server = server; - - await driver.get(`${server.host}/`); - await driver.manage().deleteAllCookies(); - await driver.manage().timeouts().implicitlyWait(100); - }); - - it ("should start", async () => { - expect(await isReady(server.host)).toEqual(true); - }); - - describeCallback(); - - afterEach(async () => { - await screenshotFailures(webdriver.driver) - }); - - afterAll(async () => { - delete global.driver; - delete global.server; - - await Promise.all([ - BackendResource.stop(server), - WebdriverResource.stop(webdriver), - SauceConnectResource.stop(sauce), - ]); - }); - })); -} - -// normally Jasmine only supports a single setup/teardown handler -// this monkey patches it to support multiple -function jasmineMultipleSetupTeardown(fn) { - return function(...args) { - // temporarily replace beforeAll etc with versions that can be called multiple times - const handlers = { beforeAll: [], beforeEach: [], afterEach: [], afterAll: [] } - const originals = {}; - - // hook the global "describe" so we know if we're in an inner describe call, - // since we only want to grab the top-level setup/teardown handlers - let originalDescribe = global.describe; - let innerDescribe = false; - global.describe = (...args) => { - innerDescribe = true; - try { - return originalDescribe.apply(this, args); - } finally { - innerDescribe = false; - } - } - - Object.keys(handlers).map((name) => { - originals[name] = global[name]; - global[name] = (fn) => { - if (innerDescribe) { - return originals[name](fn); - } else { - return handlers[name].push(fn); - } - }; - }); - - fn.apply(this, args); - - global.describe = originalDescribe; - - // restore and register actual handler - Object.keys(handlers).map((name) => { - global[name] = originals[name]; - global[name](async () => { - for (const handler of handlers[name]) { - await handler(); - } - }); - }); - } -} diff --git a/frontend/test/legacy-selenium/support/webdriver.js b/frontend/test/legacy-selenium/support/webdriver.js deleted file mode 100644 index 28736c5fcd0ea64267e254a4212641b18057570f..0000000000000000000000000000000000000000 --- a/frontend/test/legacy-selenium/support/webdriver.js +++ /dev/null @@ -1,74 +0,0 @@ -import { Builder, WebDriver } from "selenium-webdriver"; -import { USE_SAUCE, sauceCapabilities, sauceServer } from './sauce'; - -import createSharedResource from "./shared-resource"; - -const SESSION_URL = process.env["WEBDRIVER_SESSION_URL"]; -const SESSION_ID = process.env["WEBDRIVER_SESSION_ID"]; -const USE_EXISTING_SESSION = SESSION_URL && SESSION_ID; - -export const getConfig = ({ name }) => { - if (USE_SAUCE) { - return { - capabilities: { - ...sauceCapabilities, - name: name - }, - server: sauceServer - }; - } else { - return { - capabilities: { - name: name - }, - browser: "chrome" - }; - } -} - -export const WebdriverResource = createSharedResource("WebdriverResource", { - defaultOptions: {}, - getKey(options) { - return JSON.stringify(getConfig(options)) - }, - create(options) { - let config = getConfig(options); - return { - config - }; - }, - async start(webdriver) { - if (!webdriver.driver) { - if (USE_EXISTING_SESSION) { - const _http = require('selenium-webdriver/http'); - - const client = new _http.HttpClient(SESSION_URL, null, null); - const executor = new _http.Executor(client); - - webdriver.driver = await WebDriver.attachToSession(executor, SESSION_ID); - } else { - let builder = new Builder(); - if (webdriver.config.capabilities) { - builder.withCapabilities(webdriver.config.capabilities); - } - if (webdriver.config.server) { - builder.usingServer(webdriver.config.server); - } - if (webdriver.config.browser) { - builder.forBrowser(webdriver.config.browser); - } - webdriver.driver = builder.build(); - } - } - }, - async stop(webdriver) { - if (webdriver.driver) { - const driver = webdriver.driver; - delete webdriver.driver; - - if (!USE_EXISTING_SESSION) { - await driver.quit(); - } - } - } -}); diff --git a/frontend/test/protractor-conf.js b/frontend/test/protractor-conf.js deleted file mode 100644 index dc9c0c1027503b61cdbc89c1b67b21c11c5b7d88..0000000000000000000000000000000000000000 --- a/frontend/test/protractor-conf.js +++ /dev/null @@ -1,19 +0,0 @@ -exports.config = { - allScriptsTimeout: 11000, - - specs: [ - 'legacy-selenium/*.js' - ], - - capabilities: { - 'browserName': 'chrome' - }, - - baseUrl: 'http://localhost:3000/', - - framework: 'jasmine', - - jasmineNodeOpts: { - defaultTimeoutInterval: 30000 - } -}; diff --git a/frontend/test/setup/signup.integ.spec.js b/frontend/test/setup/signup.integ.spec.js index f80fcb4e85d3e8cff8f243f1565530eb0b1b442f..1206b3d9fd33622982bc9717e761bb5689a7f20e 100644 --- a/frontend/test/setup/signup.integ.spec.js +++ b/frontend/test/setup/signup.integ.spec.js @@ -115,7 +115,7 @@ describe("setup wizard", () => { const nextButton = databaseStep.find('button[children="Next"]') expect(nextButton.props().disabled).toBe(true); - const dbPath = path.resolve(__dirname, '../legacy-selenium/support/fixtures/metabase.db'); + const dbPath = path.resolve(__dirname, '../__runner__/test_db_fixture.db'); setInputValue(databaseStep.find("input[name='db']"), `file:${dbPath}`); expect(nextButton.props().disabled).toBe(undefined); diff --git a/package.json b/package.json index 341d40f271af7f6211505a83f729ec6ff3675a38..8b3701659f9cbf9ae800248362f1f005719134df 100644 --- a/package.json +++ b/package.json @@ -151,13 +151,13 @@ "lint-eslint": "eslint --ext .js --ext .jsx --max-warnings 0 frontend/src frontend/test", "lint-prettier": "prettier --tab-width 4 -l 'frontend/src/metabase/{qb,new_question}/**/*.js*' 'frontend/src/metabase-lib/**/*.js' || (echo '\nThese files are not formatted correctly. Did you forget to run \"yarn run prettier\"?' && false)", "flow": "flow check", - "test": "yarn run test-jest && yarn run test-karma", + "test": "yarn run test-integrated && yarn run test-jest && yarn run test-karma", + "test-integrated": "babel-node ./frontend/test/__runner__/run_integrated_tests.js", + "test-integrated-watch": "babel-node ./frontend/test/__runner__/run_integrated_tests.js --watch", + "test-unit": "jest --maxWorkers=10 --config jest.unit.conf.json", + "test-unit-watch": "jest --maxWorkers=10 --config jest.unit.conf.json --watch", "test-karma": "karma start frontend/test/karma.conf.js --single-run", "test-karma-watch": "karma start frontend/test/karma.conf.js --auto-watch --reporters nyan", - "test-jest": "jest --maxWorkers=10 --config jest.unit.conf.json", - "test-jest-watch": "jest --maxWorkers=10 --config jest.unit.conf.json --watch", - "test-integrated": "babel-node ./frontend/test/run-integrated-tests.js", - "test-integrated-watch": "babel-node ./frontend/test/run-integrated-tests.js --watch", "test-e2e": "JASMINE_CONFIG_PATH=./frontend/test/e2e/support/jasmine.json jasmine", "test-e2e-dev": "./frontend/test/e2e-with-persistent-browser.js", "test-e2e-sauce": "USE_SAUCE=true yarn run test-e2e",