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",