summaryrefslogtreecommitdiff
path: root/spec/frontend/__helpers__
diff options
context:
space:
mode:
authorRobert Speicher <rspeicher@gmail.com>2021-01-20 13:34:23 -0600
committerRobert Speicher <rspeicher@gmail.com>2021-01-20 13:34:23 -0600
commit6438df3a1e0fb944485cebf07976160184697d72 (patch)
tree00b09bfd170e77ae9391b1a2f5a93ef6839f2597 /spec/frontend/__helpers__
parent42bcd54d971da7ef2854b896a7b34f4ef8601067 (diff)
downloadgitlab-ce-6438df3a1e0fb944485cebf07976160184697d72.tar.gz
Add latest changes from gitlab-org/gitlab@13-8-stable-eev13.8.0-rc42
Diffstat (limited to 'spec/frontend/__helpers__')
-rw-r--r--spec/frontend/__helpers__/README.md5
-rw-r--r--spec/frontend/__helpers__/backoff_helper.js33
-rw-r--r--spec/frontend/__helpers__/class_spec_helper.js10
-rw-r--r--spec/frontend/__helpers__/class_spec_helper_spec.js26
-rw-r--r--spec/frontend/__helpers__/datetime_helpers.js6
-rw-r--r--spec/frontend/__helpers__/dom_events_helper.js8
-rw-r--r--spec/frontend/__helpers__/dom_shims/README.md12
-rw-r--r--spec/frontend/__helpers__/dom_shims/create_object_url.js3
-rw-r--r--spec/frontend/__helpers__/dom_shims/element_scroll_by.js1
-rw-r--r--spec/frontend/__helpers__/dom_shims/element_scroll_into_view.js1
-rw-r--r--spec/frontend/__helpers__/dom_shims/element_scroll_to.js6
-rw-r--r--spec/frontend/__helpers__/dom_shims/form_element.js1
-rw-r--r--spec/frontend/__helpers__/dom_shims/get_client_rects.js52
-rw-r--r--spec/frontend/__helpers__/dom_shims/get_client_rects_spec.js71
-rw-r--r--spec/frontend/__helpers__/dom_shims/image_element_properties.js12
-rw-r--r--spec/frontend/__helpers__/dom_shims/index.js12
-rw-r--r--spec/frontend/__helpers__/dom_shims/inner_text.js11
-rw-r--r--spec/frontend/__helpers__/dom_shims/range.js13
-rw-r--r--spec/frontend/__helpers__/dom_shims/scroll_by.js7
-rw-r--r--spec/frontend/__helpers__/dom_shims/size_properties.js19
-rw-r--r--spec/frontend/__helpers__/dom_shims/window_scroll_to.js1
-rw-r--r--spec/frontend/__helpers__/emoji.js88
-rw-r--r--spec/frontend/__helpers__/experimentation_helper.js14
-rw-r--r--spec/frontend/__helpers__/fake_date.js49
-rw-r--r--spec/frontend/__helpers__/fake_date_spec.js37
-rw-r--r--spec/frontend/__helpers__/fake_request_animation_frame.js12
-rw-r--r--spec/frontend/__helpers__/filtered_search_spec_helper.js69
-rw-r--r--spec/frontend/__helpers__/fixtures.js38
-rw-r--r--spec/frontend/__helpers__/init_vue_mr_page_helper.js47
-rw-r--r--spec/frontend/__helpers__/jest_helpers.js22
-rw-r--r--spec/frontend/__helpers__/jquery.js18
-rw-r--r--spec/frontend/__helpers__/keep_alive_component_helper.js29
-rw-r--r--spec/frontend/__helpers__/keep_alive_component_helper_spec.js32
-rw-r--r--spec/frontend/__helpers__/local_storage_helper.js49
-rw-r--r--spec/frontend/__helpers__/local_storage_helper_spec.js28
-rw-r--r--spec/frontend/__helpers__/locale_helper.js9
-rw-r--r--spec/frontend/__helpers__/mock_apollo_helper.js23
-rw-r--r--spec/frontend/__helpers__/mock_dom_observer.js96
-rw-r--r--spec/frontend/__helpers__/mock_window_location_helper.js43
-rw-r--r--spec/frontend/__helpers__/set_timeout_promise_helper.js4
-rw-r--r--spec/frontend/__helpers__/set_window_location_helper.js40
-rw-r--r--spec/frontend/__helpers__/set_window_location_helper_spec.js40
-rw-r--r--spec/frontend/__helpers__/stub_children.js3
-rw-r--r--spec/frontend/__helpers__/stub_component.js12
-rw-r--r--spec/frontend/__helpers__/stub_transition.js8
-rw-r--r--spec/frontend/__helpers__/test_constants.js19
-rw-r--r--spec/frontend/__helpers__/text_helper.js18
-rw-r--r--spec/frontend/__helpers__/timeout.js59
-rw-r--r--spec/frontend/__helpers__/tracking_helper.js25
-rw-r--r--spec/frontend/__helpers__/user_mock_data_helper.js29
-rw-r--r--spec/frontend/__helpers__/vue_mock_directive.js25
-rw-r--r--spec/frontend/__helpers__/vue_mount_component_helper.js63
-rw-r--r--spec/frontend/__helpers__/vue_test_utils_helper.js53
-rw-r--r--spec/frontend/__helpers__/vue_test_utils_helper_spec.js92
-rw-r--r--spec/frontend/__helpers__/vuex_action_helper.js128
-rw-r--r--spec/frontend/__helpers__/vuex_action_helper_spec.js174
-rw-r--r--spec/frontend/__helpers__/wait_for_promises.js1
-rw-r--r--spec/frontend/__helpers__/wait_for_text.js3
-rw-r--r--spec/frontend/__helpers__/wait_using_real_timer.js7
-rw-r--r--spec/frontend/__helpers__/web_worker_mock.js10
60 files changed, 1826 insertions, 0 deletions
diff --git a/spec/frontend/__helpers__/README.md b/spec/frontend/__helpers__/README.md
new file mode 100644
index 00000000000..7b6c488f820
--- /dev/null
+++ b/spec/frontend/__helpers__/README.md
@@ -0,0 +1,5 @@
+This folder contains helpers to be used in specs (and the specs for the helpers itself).
+There is a convenient alias of `helpers/`, so files in this folder (e.g. `wait_for_promises`) can be imported
+with `helpers/wait_for_promises`.
+
+If you are writing a spec for `~/helpers` application code, please place it in [helpers](../helpers).
diff --git a/spec/frontend/__helpers__/backoff_helper.js b/spec/frontend/__helpers__/backoff_helper.js
new file mode 100644
index 00000000000..a971fcb0945
--- /dev/null
+++ b/spec/frontend/__helpers__/backoff_helper.js
@@ -0,0 +1,33 @@
+/**
+ * A mock version of a commonUtils `backOff` to test multiple
+ * retries.
+ *
+ * Usage:
+ *
+ * ```
+ * import * as commonUtils from '~/lib/utils/common_utils';
+ * import { backoffMockImplementation } from '../../helpers/backoff_helper';
+ *
+ * beforeEach(() => {
+ * // ...
+ * jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation);
+ * });
+ * ```
+ *
+ * @param {Function} callback
+ */
+export const backoffMockImplementation = (callback) => {
+ const q = new Promise((resolve, reject) => {
+ const stop = (arg) => (arg instanceof Error ? reject(arg) : resolve(arg));
+ const next = () => callback(next, stop);
+ // Define a timeout based on a mock timer
+ setTimeout(() => {
+ callback(next, stop);
+ });
+ });
+ // Run all resolved promises in chain
+ jest.runOnlyPendingTimers();
+ return q;
+};
+
+export default { backoffMockImplementation };
diff --git a/spec/frontend/__helpers__/class_spec_helper.js b/spec/frontend/__helpers__/class_spec_helper.js
new file mode 100644
index 00000000000..b26f087f0c5
--- /dev/null
+++ b/spec/frontend/__helpers__/class_spec_helper.js
@@ -0,0 +1,10 @@
+// eslint-disable-next-line jest/no-export
+export default class ClassSpecHelper {
+ static itShouldBeAStaticMethod(base, method) {
+ return it('should be a static method', () => {
+ expect(Object.prototype.hasOwnProperty.call(base, method)).toBeTruthy();
+ });
+ }
+}
+
+window.ClassSpecHelper = ClassSpecHelper;
diff --git a/spec/frontend/__helpers__/class_spec_helper_spec.js b/spec/frontend/__helpers__/class_spec_helper_spec.js
new file mode 100644
index 00000000000..533d5687bde
--- /dev/null
+++ b/spec/frontend/__helpers__/class_spec_helper_spec.js
@@ -0,0 +1,26 @@
+/* global ClassSpecHelper */
+
+import './class_spec_helper';
+
+describe('ClassSpecHelper', () => {
+ let testContext;
+
+ beforeEach(() => {
+ testContext = {};
+ });
+
+ describe('itShouldBeAStaticMethod', () => {
+ beforeEach(() => {
+ class TestClass {
+ instanceMethod() {
+ this.prop = 'val';
+ }
+ static staticMethod() {}
+ }
+
+ testContext.TestClass = TestClass;
+ });
+
+ ClassSpecHelper.itShouldBeAStaticMethod(ClassSpecHelper, 'itShouldBeAStaticMethod');
+ });
+});
diff --git a/spec/frontend/__helpers__/datetime_helpers.js b/spec/frontend/__helpers__/datetime_helpers.js
new file mode 100644
index 00000000000..25dbd1d477d
--- /dev/null
+++ b/spec/frontend/__helpers__/datetime_helpers.js
@@ -0,0 +1,6 @@
+import dateFormat from 'dateformat';
+
+/**
+ * Returns a date object corresponding to the given date string.
+ */
+export const dateFromString = (dateString) => new Date(dateFormat(dateString));
diff --git a/spec/frontend/__helpers__/dom_events_helper.js b/spec/frontend/__helpers__/dom_events_helper.js
new file mode 100644
index 00000000000..865ea97903f
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_events_helper.js
@@ -0,0 +1,8 @@
+export const triggerDOMEvent = (type) => {
+ window.document.dispatchEvent(
+ new Event(type, {
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+};
diff --git a/spec/frontend/__helpers__/dom_shims/README.md b/spec/frontend/__helpers__/dom_shims/README.md
new file mode 100644
index 00000000000..1105e4b0c4c
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/README.md
@@ -0,0 +1,12 @@
+## Jest DOM shims
+
+This is where we shim parts of JSDom. It is imported in our root `test_setup.js`.
+
+### Why do we need this?
+
+Since JSDom mocks a real DOM environment (which is a good thing), it
+unfortunately does not support some jQuery matchers.
+
+### References
+
+- https://gitlab.com/gitlab-org/gitlab/merge_requests/17906#note_224448120
diff --git a/spec/frontend/__helpers__/dom_shims/create_object_url.js b/spec/frontend/__helpers__/dom_shims/create_object_url.js
new file mode 100644
index 00000000000..94d060cab08
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/create_object_url.js
@@ -0,0 +1,3 @@
+URL.createObjectURL = function createObjectURL() {
+ return 'blob:https://gitlab.com/048c7ac1-98de-4a37-ab1b-0206d0ea7e1b';
+};
diff --git a/spec/frontend/__helpers__/dom_shims/element_scroll_by.js b/spec/frontend/__helpers__/dom_shims/element_scroll_by.js
new file mode 100644
index 00000000000..7d91279e4aa
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/element_scroll_by.js
@@ -0,0 +1 @@
+Element.prototype.scrollBy = jest.fn();
diff --git a/spec/frontend/__helpers__/dom_shims/element_scroll_into_view.js b/spec/frontend/__helpers__/dom_shims/element_scroll_into_view.js
new file mode 100644
index 00000000000..a7262d04db0
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/element_scroll_into_view.js
@@ -0,0 +1 @@
+Element.prototype.scrollIntoView = jest.fn();
diff --git a/spec/frontend/__helpers__/dom_shims/element_scroll_to.js b/spec/frontend/__helpers__/dom_shims/element_scroll_to.js
new file mode 100644
index 00000000000..68f8a115865
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/element_scroll_to.js
@@ -0,0 +1,6 @@
+Element.prototype.scrollTo = jest.fn().mockImplementation(function scrollTo(x, y) {
+ this.scrollLeft = x;
+ this.scrollTop = y;
+
+ this.dispatchEvent(new Event('scroll'));
+});
diff --git a/spec/frontend/__helpers__/dom_shims/form_element.js b/spec/frontend/__helpers__/dom_shims/form_element.js
new file mode 100644
index 00000000000..46ef0374848
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/form_element.js
@@ -0,0 +1 @@
+HTMLFormElement.prototype.submit = jest.fn();
diff --git a/spec/frontend/__helpers__/dom_shims/get_client_rects.js b/spec/frontend/__helpers__/dom_shims/get_client_rects.js
new file mode 100644
index 00000000000..7ba60dd7936
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/get_client_rects.js
@@ -0,0 +1,52 @@
+function hasHiddenStyle(node) {
+ if (!node.style) {
+ return false;
+ } else if (node.style.display === 'none' || node.style.visibility === 'hidden') {
+ return true;
+ }
+
+ return false;
+}
+
+function createDefaultClientRect(node) {
+ const { outerWidth: width, outerHeight: height } = node;
+
+ return {
+ bottom: height,
+ height,
+ left: 0,
+ right: width,
+ top: 0,
+ width,
+ x: 0,
+ y: 0,
+ };
+}
+
+/**
+ * This is needed to get the `toBeVisible` matcher to work in `jsdom`
+ *
+ * Reference:
+ * - https://github.com/jsdom/jsdom/issues/1322
+ * - https://github.com/unindented/custom-jquery-matchers/blob/v2.1.0/packages/custom-jquery-matchers/src/matchers.js#L157
+ */
+window.Element.prototype.getClientRects = function getClientRects() {
+ let node = this;
+
+ while (node) {
+ if (node === document) {
+ break;
+ }
+
+ if (hasHiddenStyle(node)) {
+ return [];
+ }
+ node = node.parentNode;
+ }
+
+ if (!node) {
+ return [];
+ }
+
+ return [createDefaultClientRect(node)];
+};
diff --git a/spec/frontend/__helpers__/dom_shims/get_client_rects_spec.js b/spec/frontend/__helpers__/dom_shims/get_client_rects_spec.js
new file mode 100644
index 00000000000..e7b8f1e235b
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/get_client_rects_spec.js
@@ -0,0 +1,71 @@
+const createTestElement = () => {
+ const element = document.createElement('div');
+
+ element.textContent = 'Hello World!';
+
+ return element;
+};
+
+describe('DOM patch for getClientRects', () => {
+ let origHtml;
+ let el;
+
+ beforeEach(() => {
+ origHtml = document.body.innerHTML;
+ el = createTestElement();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = origHtml;
+ });
+
+ describe('toBeVisible matcher', () => {
+ describe('when not attached to document', () => {
+ it('does not match', () => {
+ expect(el).not.toBeVisible();
+ });
+ });
+
+ describe('when attached to document', () => {
+ beforeEach(() => {
+ document.body.appendChild(el);
+ });
+
+ it('matches', () => {
+ expect(el).toBeVisible();
+ });
+ });
+
+ describe('with parent and attached to document', () => {
+ let parentEl;
+
+ beforeEach(() => {
+ parentEl = createTestElement();
+ parentEl.appendChild(el);
+ document.body.appendChild(parentEl);
+ });
+
+ it('matches', () => {
+ expect(el).toBeVisible();
+ });
+
+ describe.each`
+ style
+ ${{ display: 'none' }}
+ ${{ visibility: 'hidden' }}
+ `('with style $style', ({ style }) => {
+ it('does not match when applied to element', () => {
+ Object.assign(el.style, style);
+
+ expect(el).not.toBeVisible();
+ });
+
+ it('does not match when applied to parent', () => {
+ Object.assign(parentEl.style, style);
+
+ expect(el).not.toBeVisible();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/__helpers__/dom_shims/image_element_properties.js b/spec/frontend/__helpers__/dom_shims/image_element_properties.js
new file mode 100644
index 00000000000..d94c157e44d
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/image_element_properties.js
@@ -0,0 +1,12 @@
+Object.defineProperty(global.HTMLImageElement.prototype, 'src', {
+ get() {
+ return this.$_jest_src || this.getAttribute('src');
+ },
+ set(val) {
+ this.$_jest_src = val;
+
+ if (this.onload) {
+ this.onload();
+ }
+ },
+});
diff --git a/spec/frontend/__helpers__/dom_shims/index.js b/spec/frontend/__helpers__/dom_shims/index.js
new file mode 100644
index 00000000000..9b70cb86b8b
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/index.js
@@ -0,0 +1,12 @@
+import './create_object_url';
+import './element_scroll_into_view';
+import './element_scroll_by';
+import './element_scroll_to';
+import './form_element';
+import './get_client_rects';
+import './inner_text';
+import './range';
+import './window_scroll_to';
+import './scroll_by';
+import './size_properties';
+import './image_element_properties';
diff --git a/spec/frontend/__helpers__/dom_shims/inner_text.js b/spec/frontend/__helpers__/dom_shims/inner_text.js
new file mode 100644
index 00000000000..2b8201eed31
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/inner_text.js
@@ -0,0 +1,11 @@
+// workaround for JSDOM not supporting innerText
+// see https://github.com/jsdom/jsdom/issues/1245
+Object.defineProperty(global.Element.prototype, 'innerText', {
+ get() {
+ return this.textContent;
+ },
+ set(value) {
+ this.textContext = value;
+ },
+ configurable: true, // make it so that it doesn't blow chunks on re-running tests with things like --watch
+});
diff --git a/spec/frontend/__helpers__/dom_shims/range.js b/spec/frontend/__helpers__/dom_shims/range.js
new file mode 100644
index 00000000000..4ffdf3280ad
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/range.js
@@ -0,0 +1,13 @@
+if (window.Range.prototype.getBoundingClientRect) {
+ throw new Error('window.Range.prototype.getBoundingClientRect already exists. Remove this stub!');
+}
+window.Range.prototype.getBoundingClientRect = function getBoundingClientRect() {
+ return { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 };
+};
+
+if (window.Range.prototype.getClientRects) {
+ throw new Error('window.Range.prototype.getClientRects already exists. Remove this stub!');
+}
+window.Range.prototype.getClientRects = function getClientRects() {
+ return [this.getBoundingClientRect()];
+};
diff --git a/spec/frontend/__helpers__/dom_shims/scroll_by.js b/spec/frontend/__helpers__/dom_shims/scroll_by.js
new file mode 100644
index 00000000000..90387e51765
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/scroll_by.js
@@ -0,0 +1,7 @@
+window.scrollX = 0;
+window.scrollY = 0;
+
+window.scrollBy = (x, y) => {
+ window.scrollX += x;
+ window.scrollY += y;
+};
diff --git a/spec/frontend/__helpers__/dom_shims/size_properties.js b/spec/frontend/__helpers__/dom_shims/size_properties.js
new file mode 100644
index 00000000000..fbb919868b1
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/size_properties.js
@@ -0,0 +1,19 @@
+const convertFromStyle = (style) => {
+ if (style.match(/[0-9](px|rem)/g)) {
+ return Number(style.replace(/[^0-9]/g, ''));
+ }
+
+ return 0;
+};
+
+Object.defineProperty(global.HTMLElement.prototype, 'offsetWidth', {
+ get() {
+ return convertFromStyle(this.style.width || '0px');
+ },
+});
+
+Object.defineProperty(global.HTMLElement.prototype, 'offsetHeight', {
+ get() {
+ return convertFromStyle(this.style.height || '0px');
+ },
+});
diff --git a/spec/frontend/__helpers__/dom_shims/window_scroll_to.js b/spec/frontend/__helpers__/dom_shims/window_scroll_to.js
new file mode 100644
index 00000000000..20ae1910bf3
--- /dev/null
+++ b/spec/frontend/__helpers__/dom_shims/window_scroll_to.js
@@ -0,0 +1 @@
+window.scrollTo = jest.fn();
diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js
new file mode 100644
index 00000000000..ea6613b53c9
--- /dev/null
+++ b/spec/frontend/__helpers__/emoji.js
@@ -0,0 +1,88 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import { initEmojiMap, EMOJI_VERSION } from '~/emoji';
+
+export const emojiFixtureMap = {
+ atom: {
+ moji: '⚛',
+ description: 'atom symbol',
+ unicodeVersion: '4.1',
+ aliases: ['atom_symbol'],
+ },
+ bomb: {
+ moji: '💣',
+ unicodeVersion: '6.0',
+ description: 'bomb',
+ },
+ construction_worker_tone5: {
+ moji: '👷🏿',
+ unicodeVersion: '8.0',
+ description: 'construction worker tone 5',
+ },
+ five: {
+ moji: '5️⃣',
+ unicodeVersion: '3.0',
+ description: 'keycap digit five',
+ },
+ grey_question: {
+ moji: '❔',
+ unicodeVersion: '6.0',
+ description: 'white question mark ornament',
+ },
+
+ // used for regression tests
+ // black_heart MUST come before heart
+ // custard MUST come before star
+ black_heart: {
+ moji: '🖤',
+ unicodeVersion: '1.1',
+ description: 'black heart',
+ },
+ heart: {
+ moji: '❤',
+ unicodeVersion: '1.1',
+ description: 'heavy black heart',
+ },
+ custard: {
+ moji: '🍮',
+ unicodeVersion: '6.0',
+ description: 'custard',
+ },
+ star: {
+ moji: '⭐',
+ unicodeVersion: '5.1',
+ description: 'white medium star',
+ },
+};
+
+Object.keys(emojiFixtureMap).forEach((k) => {
+ emojiFixtureMap[k].name = k;
+ if (!emojiFixtureMap[k].aliases) {
+ emojiFixtureMap[k].aliases = [];
+ }
+});
+
+export async function initEmojiMock() {
+ const emojiData = Object.fromEntries(
+ Object.values(emojiFixtureMap).map((m) => {
+ const { name: n, moji: e, unicodeVersion: u, category: c, description: d } = m;
+ return [n, { c, e, d, u }];
+ }),
+ );
+
+ const mock = new MockAdapter(axios);
+ mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData));
+
+ await initEmojiMap();
+
+ return mock;
+}
+
+export function describeEmojiFields(label, tests) {
+ describe.each`
+ field | accessor
+ ${'name'} | ${(e) => e.name}
+ ${'alias'} | ${(e) => e.aliases[0]}
+ ${'description'} | ${(e) => e.description}
+ `(label, tests);
+}
diff --git a/spec/frontend/__helpers__/experimentation_helper.js b/spec/frontend/__helpers__/experimentation_helper.js
new file mode 100644
index 00000000000..c08c25155e8
--- /dev/null
+++ b/spec/frontend/__helpers__/experimentation_helper.js
@@ -0,0 +1,14 @@
+import { merge } from 'lodash';
+
+export function withGonExperiment(experimentKey, value = true) {
+ let origGon;
+
+ beforeEach(() => {
+ origGon = window.gon;
+ window.gon = merge({}, window.gon || {}, { experiments: { [experimentKey]: value } });
+ });
+
+ afterEach(() => {
+ window.gon = origGon;
+ });
+}
diff --git a/spec/frontend/__helpers__/fake_date.js b/spec/frontend/__helpers__/fake_date.js
new file mode 100644
index 00000000000..5391ae04797
--- /dev/null
+++ b/spec/frontend/__helpers__/fake_date.js
@@ -0,0 +1,49 @@
+// Frida Kahlo's birthday (6 = July)
+export const DEFAULT_ARGS = [2020, 6, 6];
+
+const RealDate = Date;
+
+const isMocked = (val) => Boolean(val.mock);
+
+export const createFakeDateClass = (ctorDefault) => {
+ const FakeDate = new Proxy(RealDate, {
+ construct: (target, argArray) => {
+ const ctorArgs = argArray.length ? argArray : ctorDefault;
+
+ return new RealDate(...ctorArgs);
+ },
+ apply: (target, thisArg, argArray) => {
+ const ctorArgs = argArray.length ? argArray : ctorDefault;
+
+ return new RealDate(...ctorArgs).toString();
+ },
+ // We want to overwrite the default 'now', but only if it's not already mocked
+ get: (target, prop) => {
+ if (prop === 'now' && !isMocked(target[prop])) {
+ return () => new RealDate(...ctorDefault).getTime();
+ }
+
+ return target[prop];
+ },
+ getPrototypeOf: (target) => {
+ return target.prototype;
+ },
+ // We need to be able to set props so that `jest.spyOn` will work.
+ set: (target, prop, value) => {
+ // eslint-disable-next-line no-param-reassign
+ target[prop] = value;
+ return true;
+ },
+ });
+
+ return FakeDate;
+};
+
+export const useFakeDate = (...args) => {
+ const FakeDate = createFakeDateClass(args.length ? args : DEFAULT_ARGS);
+ global.Date = FakeDate;
+};
+
+export const useRealDate = () => {
+ global.Date = RealDate;
+};
diff --git a/spec/frontend/__helpers__/fake_date_spec.js b/spec/frontend/__helpers__/fake_date_spec.js
new file mode 100644
index 00000000000..b3ed13e238a
--- /dev/null
+++ b/spec/frontend/__helpers__/fake_date_spec.js
@@ -0,0 +1,37 @@
+import { createFakeDateClass, DEFAULT_ARGS, useRealDate } from './fake_date';
+
+describe('spec/helpers/fake_date', () => {
+ describe('createFakeDateClass', () => {
+ let FakeDate;
+
+ beforeAll(() => {
+ useRealDate();
+ });
+
+ beforeEach(() => {
+ FakeDate = createFakeDateClass(DEFAULT_ARGS);
+ });
+
+ it('should use default args', () => {
+ expect(new FakeDate()).toMatchInlineSnapshot(`2020-07-06T00:00:00.000Z`);
+ });
+
+ it('should use default args when called as a function', () => {
+ expect(FakeDate()).toMatchInlineSnapshot(
+ `"Mon Jul 06 2020 00:00:00 GMT+0000 (Greenwich Mean Time)"`,
+ );
+ });
+
+ it('should have deterministic now()', () => {
+ expect(FakeDate.now()).toMatchInlineSnapshot(`1593993600000`);
+ });
+
+ it('should be instanceof Date', () => {
+ expect(new FakeDate()).toBeInstanceOf(Date);
+ });
+
+ it('should be instanceof self', () => {
+ expect(new FakeDate()).toBeInstanceOf(FakeDate);
+ });
+ });
+});
diff --git a/spec/frontend/__helpers__/fake_request_animation_frame.js b/spec/frontend/__helpers__/fake_request_animation_frame.js
new file mode 100644
index 00000000000..ca9d82b6c3a
--- /dev/null
+++ b/spec/frontend/__helpers__/fake_request_animation_frame.js
@@ -0,0 +1,12 @@
+export const useFakeRequestAnimationFrame = () => {
+ let orig;
+
+ beforeEach(() => {
+ orig = global.requestAnimationFrame;
+ global.requestAnimationFrame = (cb) => cb();
+ });
+
+ afterEach(() => {
+ global.requestAnimationFrame = orig;
+ });
+};
diff --git a/spec/frontend/__helpers__/filtered_search_spec_helper.js b/spec/frontend/__helpers__/filtered_search_spec_helper.js
new file mode 100644
index 00000000000..ecf10694a16
--- /dev/null
+++ b/spec/frontend/__helpers__/filtered_search_spec_helper.js
@@ -0,0 +1,69 @@
+export default class FilteredSearchSpecHelper {
+ static createFilterVisualTokenHTML(name, operator, value, isSelected) {
+ return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected)
+ .outerHTML;
+ }
+
+ static createFilterVisualToken(name, operator, value, isSelected = false) {
+ const li = document.createElement('li');
+ li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
+
+ li.innerHTML = `
+ <div class="selectable ${isSelected ? 'selected' : ''}" role="button">
+ <div class="name">${name}</div>
+ <div class="operator">${operator}</div>
+ <div class="value-container">
+ <div class="value">${value}</div>
+ <div class="remove-token" role="button">
+ <svg class="s16 close-icon"></svg>
+ </div>
+ </div>
+ </div>
+ `;
+
+ return li;
+ }
+
+ static createNameFilterVisualTokenHTML(name) {
+ return `
+ <li class="js-visual-token filtered-search-token">
+ <div class="name">${name}</div>
+ </li>
+ `;
+ }
+
+ static createNameOperatorFilterVisualTokenHTML(name, operator) {
+ return `
+ <li class="js-visual-token filtered-search-token">
+ <div class="name">${name}</div>
+ <div class="operator">${operator}</div>
+ </li>
+ `;
+ }
+
+ static createSearchVisualToken(name) {
+ const li = document.createElement('li');
+ li.classList.add('js-visual-token', 'filtered-search-term');
+ li.innerHTML = `<div class="name">${name}</div>`;
+ return li;
+ }
+
+ static createSearchVisualTokenHTML(name) {
+ return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML;
+ }
+
+ static createInputHTML(placeholder = '', value = '') {
+ return `
+ <li class="input-token">
+ <input type='text' class='filtered-search' placeholder='${placeholder}' value='${value}'/>
+ </li>
+ `;
+ }
+
+ static createTokensContainerHTML(html, inputPlaceholder) {
+ return `
+ ${html}
+ ${FilteredSearchSpecHelper.createInputHTML(inputPlaceholder)}
+ `;
+ }
+}
diff --git a/spec/frontend/__helpers__/fixtures.js b/spec/frontend/__helpers__/fixtures.js
new file mode 100644
index 00000000000..4b86724df93
--- /dev/null
+++ b/spec/frontend/__helpers__/fixtures.js
@@ -0,0 +1,38 @@
+import fs from 'fs';
+import path from 'path';
+
+import { ErrorWithStack } from 'jest-util';
+
+export function getFixture(relativePath) {
+ const basePath = relativePath.startsWith('static/')
+ ? global.staticFixturesBasePath
+ : global.fixturesBasePath;
+ const absolutePath = path.join(basePath, relativePath);
+ if (!fs.existsSync(absolutePath)) {
+ throw new ErrorWithStack(
+ `Fixture file ${relativePath} does not exist.
+
+Did you run bin/rake frontend:fixtures?`,
+ getFixture,
+ );
+ }
+
+ return fs.readFileSync(absolutePath, 'utf8');
+}
+
+export const getJSONFixture = (relativePath) => JSON.parse(getFixture(relativePath));
+
+export const resetHTMLFixture = () => {
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
+};
+
+export const setHTMLFixture = (htmlContent, resetHook = afterEach) => {
+ document.body.innerHTML = htmlContent;
+ resetHook(resetHTMLFixture);
+};
+
+export const loadHTMLFixture = (relativePath, resetHook = afterEach) => {
+ const fileContent = getFixture(relativePath);
+ setHTMLFixture(fileContent, resetHook);
+};
diff --git a/spec/frontend/__helpers__/init_vue_mr_page_helper.js b/spec/frontend/__helpers__/init_vue_mr_page_helper.js
new file mode 100644
index 00000000000..b9aed63d0f6
--- /dev/null
+++ b/spec/frontend/__helpers__/init_vue_mr_page_helper.js
@@ -0,0 +1,47 @@
+import MockAdapter from 'axios-mock-adapter';
+import initMRPage from '~/mr_notes';
+import axios from '~/lib/utils/axios_utils';
+import { userDataMock, notesDataMock, noteableDataMock } from '../notes/mock_data';
+import diffFileMockData from '../diffs/mock_data/diff_file';
+
+export default function initVueMRPage() {
+ const mrTestEl = document.createElement('div');
+ mrTestEl.className = 'js-merge-request-test';
+ document.body.appendChild(mrTestEl);
+
+ const diffsAppEndpoint = '/diffs/app/endpoint';
+ const diffsAppProjectPath = 'testproject';
+ const mrEl = document.createElement('div');
+ mrEl.className = 'merge-request fixture-mr';
+ mrEl.setAttribute('data-mr-action', 'diffs');
+ mrTestEl.appendChild(mrEl);
+
+ const mrDiscussionsEl = document.createElement('div');
+ mrDiscussionsEl.id = 'js-vue-mr-discussions';
+ mrDiscussionsEl.setAttribute('data-current-user-data', JSON.stringify(userDataMock));
+ mrDiscussionsEl.setAttribute('data-noteable-data', JSON.stringify(noteableDataMock));
+ mrDiscussionsEl.setAttribute('data-notes-data', JSON.stringify(notesDataMock));
+ mrDiscussionsEl.setAttribute('data-noteable-type', 'merge-request');
+ mrDiscussionsEl.setAttribute('data-is-locked', 'false');
+ mrTestEl.appendChild(mrDiscussionsEl);
+
+ const discussionCounterEl = document.createElement('div');
+ discussionCounterEl.id = 'js-vue-discussion-counter';
+ mrTestEl.appendChild(discussionCounterEl);
+
+ const diffsAppEl = document.createElement('div');
+ diffsAppEl.id = 'js-diffs-app';
+ diffsAppEl.setAttribute('data-endpoint', diffsAppEndpoint);
+ diffsAppEl.setAttribute('data-project-path', diffsAppProjectPath);
+ diffsAppEl.setAttribute('data-current-user-data', JSON.stringify(userDataMock));
+ mrTestEl.appendChild(diffsAppEl);
+
+ const mock = new MockAdapter(axios);
+ mock.onGet(diffsAppEndpoint).reply(200, {
+ branch_name: 'foo',
+ diff_files: [diffFileMockData],
+ });
+
+ initMRPage();
+ return mock;
+}
diff --git a/spec/frontend/__helpers__/jest_helpers.js b/spec/frontend/__helpers__/jest_helpers.js
new file mode 100644
index 00000000000..273d2c91966
--- /dev/null
+++ b/spec/frontend/__helpers__/jest_helpers.js
@@ -0,0 +1,22 @@
+/*
+@module
+
+This method provides convenience functions to help migrating from Karma/Jasmine to Jest.
+
+Try not to use these in new tests - this module is provided primarily for convenience of migrating tests.
+ */
+
+/**
+ * Creates a plain JS object pre-populated with Jest spy functions. Useful for making simple mocks classes.
+ *
+ * @see https://jasmine.github.io/2.0/introduction.html#section-Spies:_%3Ccode%3EcreateSpyObj%3C/code%3E
+ * @param {string} baseName Human-readable name of the object. This is used for reporting purposes.
+ * @param methods {string[]} List of method names that will be added to the spy object.
+ */
+export function createSpyObj(baseName, methods) {
+ const obj = {};
+ methods.forEach((method) => {
+ obj[method] = jest.fn().mockName(`${baseName}#${method}`);
+ });
+ return obj;
+}
diff --git a/spec/frontend/__helpers__/jquery.js b/spec/frontend/__helpers__/jquery.js
new file mode 100644
index 00000000000..4af5f904394
--- /dev/null
+++ b/spec/frontend/__helpers__/jquery.js
@@ -0,0 +1,18 @@
+import $ from 'jquery';
+
+// Expose jQuery so specs using jQuery plugins can be imported nicely.
+// Here is an issue to explore better alternatives:
+// https://gitlab.com/gitlab-org/gitlab/issues/12448
+global.$ = $;
+global.jQuery = $;
+
+// Fail tests for unmocked requests
+$.ajax = () => {
+ const err = new Error(
+ 'Unexpected unmocked jQuery.ajax() call! Make sure to mock jQuery.ajax() in tests.',
+ );
+ global.fail(err);
+ throw err;
+};
+
+export default $;
diff --git a/spec/frontend/__helpers__/keep_alive_component_helper.js b/spec/frontend/__helpers__/keep_alive_component_helper.js
new file mode 100644
index 00000000000..54f40bf9093
--- /dev/null
+++ b/spec/frontend/__helpers__/keep_alive_component_helper.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+
+export function keepAlive(KeptAliveComponent) {
+ return Vue.extend({
+ components: {
+ KeptAliveComponent,
+ },
+ data() {
+ return {
+ view: 'KeptAliveComponent',
+ };
+ },
+ methods: {
+ async activate() {
+ this.view = 'KeptAliveComponent';
+ await this.$nextTick();
+ },
+ async deactivate() {
+ this.view = 'div';
+ await this.$nextTick();
+ },
+ async reactivate() {
+ await this.deactivate();
+ await this.activate();
+ },
+ },
+ template: `<keep-alive><component :is="view"></component></keep-alive>`,
+ });
+}
diff --git a/spec/frontend/__helpers__/keep_alive_component_helper_spec.js b/spec/frontend/__helpers__/keep_alive_component_helper_spec.js
new file mode 100644
index 00000000000..dcccc14f396
--- /dev/null
+++ b/spec/frontend/__helpers__/keep_alive_component_helper_spec.js
@@ -0,0 +1,32 @@
+import { mount } from '@vue/test-utils';
+import { keepAlive } from './keep_alive_component_helper';
+
+const component = {
+ template: '<div>Test Component</div>',
+};
+
+describe('keepAlive', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(keepAlive(component));
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('converts a component to a keep-alive component', async () => {
+ const { element } = wrapper.find(component);
+
+ await wrapper.vm.deactivate();
+ expect(wrapper.find(component).exists()).toBe(false);
+
+ await wrapper.vm.activate();
+
+ // assert that when the component is destroyed and re-rendered, the
+ // newly rendered component has the reference to the old component
+ // (i.e. the old component was deactivated and activated)
+ expect(wrapper.find(component).element).toBe(element);
+ });
+});
diff --git a/spec/frontend/__helpers__/local_storage_helper.js b/spec/frontend/__helpers__/local_storage_helper.js
new file mode 100644
index 00000000000..21749fd8070
--- /dev/null
+++ b/spec/frontend/__helpers__/local_storage_helper.js
@@ -0,0 +1,49 @@
+/**
+ * Manage the instance of a custom `window.localStorage`
+ *
+ * This only encapsulates the setup / teardown logic so that it can easily be
+ * reused with different implementations (i.e. a spy or a [fake][1])
+ *
+ * [1]: https://stackoverflow.com/a/41434763/1708147
+ *
+ * @param {() => any} fn Function that returns the object to use for localStorage
+ */
+const useLocalStorage = (fn) => {
+ const origLocalStorage = window.localStorage;
+ let currentLocalStorage = origLocalStorage;
+
+ Object.defineProperty(window, 'localStorage', {
+ get: () => currentLocalStorage,
+ });
+
+ beforeEach(() => {
+ currentLocalStorage = fn();
+ });
+
+ afterEach(() => {
+ currentLocalStorage = origLocalStorage;
+ });
+};
+
+/**
+ * Create an object with the localStorage interface but `jest.fn()` implementations.
+ */
+export const createLocalStorageSpy = () => {
+ let storage = {};
+
+ return {
+ clear: jest.fn(() => {
+ storage = {};
+ }),
+ getItem: jest.fn((key) => (key in storage ? storage[key] : null)),
+ setItem: jest.fn((key, value) => {
+ storage[key] = value;
+ }),
+ removeItem: jest.fn((key) => delete storage[key]),
+ };
+};
+
+/**
+ * Before each test, overwrite `window.localStorage` with a spy implementation.
+ */
+export const useLocalStorageSpy = () => useLocalStorage(createLocalStorageSpy);
diff --git a/spec/frontend/__helpers__/local_storage_helper_spec.js b/spec/frontend/__helpers__/local_storage_helper_spec.js
new file mode 100644
index 00000000000..5d9961e7631
--- /dev/null
+++ b/spec/frontend/__helpers__/local_storage_helper_spec.js
@@ -0,0 +1,28 @@
+import { useLocalStorageSpy } from './local_storage_helper';
+
+describe('block before helper is installed', () => {
+ it('should leave original localStorage intact', () => {
+ expect(localStorage.getItem).toEqual(expect.any(Function));
+ expect(jest.isMockFunction(localStorage.getItem)).toBe(false);
+ });
+});
+
+describe('localStorage helper', () => {
+ useLocalStorageSpy();
+
+ it('mocks localStorage but works exactly like original localStorage', () => {
+ localStorage.setItem('test', 'testing');
+ localStorage.setItem('test2', 'testing');
+
+ expect(localStorage.getItem('test')).toBe('testing');
+
+ localStorage.removeItem('test', 'testing');
+
+ expect(localStorage.getItem('test')).toBe(null);
+ expect(localStorage.getItem('test2')).toBe('testing');
+
+ localStorage.clear();
+
+ expect(localStorage.getItem('test2')).toBe(null);
+ });
+});
diff --git a/spec/frontend/__helpers__/locale_helper.js b/spec/frontend/__helpers__/locale_helper.js
new file mode 100644
index 00000000000..bb4a2eccf4e
--- /dev/null
+++ b/spec/frontend/__helpers__/locale_helper.js
@@ -0,0 +1,9 @@
+export const setLanguage = (languageCode) => {
+ const htmlElement = document.querySelector('html');
+
+ if (languageCode) {
+ htmlElement.setAttribute('lang', languageCode);
+ } else {
+ htmlElement.removeAttribute('lang');
+ }
+};
diff --git a/spec/frontend/__helpers__/mock_apollo_helper.js b/spec/frontend/__helpers__/mock_apollo_helper.js
new file mode 100644
index 00000000000..914cce1d662
--- /dev/null
+++ b/spec/frontend/__helpers__/mock_apollo_helper.js
@@ -0,0 +1,23 @@
+import { InMemoryCache } from 'apollo-cache-inmemory';
+import { createMockClient } from 'mock-apollo-client';
+import VueApollo from 'vue-apollo';
+
+export default (handlers = [], resolvers = {}) => {
+ const fragmentMatcher = { match: () => true };
+ const cache = new InMemoryCache({
+ fragmentMatcher,
+ addTypename: false,
+ });
+
+ const mockClient = createMockClient({ cache, resolvers });
+
+ if (Array.isArray(handlers)) {
+ handlers.forEach(([query, value]) => mockClient.setRequestHandler(query, value));
+ } else {
+ throw new Error('You should pass an array of handlers to mock Apollo client');
+ }
+
+ const apolloProvider = new VueApollo({ defaultClient: mockClient });
+
+ return apolloProvider;
+};
diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js
new file mode 100644
index 00000000000..1b93b81535d
--- /dev/null
+++ b/spec/frontend/__helpers__/mock_dom_observer.js
@@ -0,0 +1,96 @@
+/* eslint-disable class-methods-use-this, max-classes-per-file */
+import { isMatch } from 'lodash';
+
+/**
+ * This class gives us a JSDom friendly DOM observer which we can manually trigger in tests
+ *
+ * Use this in place of MutationObserver or IntersectionObserver
+ */
+class MockObserver {
+ constructor(cb) {
+ this.$_cb = cb;
+ this.$_observers = [];
+ }
+
+ observe(node, options = {}) {
+ this.$_observers.push([node, options]);
+ }
+
+ disconnect() {
+ this.$_observers = [];
+ }
+
+ takeRecords() {}
+
+ // eslint-disable-next-line babel/camelcase
+ $_triggerObserve(node, { entry = {}, options = {} } = {}) {
+ if (this.$_hasObserver(node, options)) {
+ this.$_cb([{ target: node, ...entry }]);
+ }
+ }
+
+ // eslint-disable-next-line babel/camelcase
+ $_hasObserver(node, options = {}) {
+ return this.$_observers.some(
+ ([obvNode, obvOptions]) => node === obvNode && isMatch(options, obvOptions),
+ );
+ }
+}
+
+class MockIntersectionObserver extends MockObserver {
+ unobserve(node) {
+ this.$_observers = this.$_observers.filter(([obvNode]) => node === obvNode);
+ }
+}
+
+/**
+ * Use this function to setup a mock observer instance in place of the given DOM Observer
+ *
+ * Example:
+ * ```
+ * describe('', () => {
+ * const { trigger: triggerMutate } = useMockMutationObserver();
+ *
+ * it('test', () => {
+ * trigger(el, { options: { childList: true }, entry: { } });
+ * });
+ * })
+ * ```
+ *
+ * @param {String} key
+ */
+const useMockObserver = (key, createMock) => {
+ let mockObserver;
+ let origObserver;
+
+ beforeEach(() => {
+ origObserver = global[key];
+ global[key] = jest.fn().mockImplementation((...args) => {
+ mockObserver = createMock(...args);
+ return mockObserver;
+ });
+ });
+
+ afterEach(() => {
+ mockObserver = null;
+ global[key] = origObserver;
+ });
+
+ const trigger = (...args) => {
+ if (!mockObserver) {
+ return;
+ }
+
+ mockObserver.$_triggerObserve(...args);
+ };
+
+ const observersCount = () => mockObserver.$_observers.length;
+
+ return { trigger, observersCount };
+};
+
+export const useMockIntersectionObserver = () =>
+ useMockObserver('IntersectionObserver', (...args) => new MockIntersectionObserver(...args));
+
+export const useMockMutationObserver = () =>
+ useMockObserver('MutationObserver', (...args) => new MockObserver(...args));
diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js
new file mode 100644
index 00000000000..08a28fbbbd6
--- /dev/null
+++ b/spec/frontend/__helpers__/mock_window_location_helper.js
@@ -0,0 +1,43 @@
+/**
+ * Manage the instance of a custom `window.location`
+ *
+ * This only encapsulates the setup / teardown logic so that it can easily be
+ * reused with different implementations (i.e. a spy or a [fake][1])
+ *
+ * [1]: https://stackoverflow.com/a/41434763/1708147
+ *
+ * @param {() => any} fn Function that returns the object to use for window.location
+ */
+const useMockLocation = (fn) => {
+ const origWindowLocation = window.location;
+ let currentWindowLocation;
+
+ Object.defineProperty(window, 'location', {
+ get: () => currentWindowLocation,
+ });
+
+ beforeEach(() => {
+ currentWindowLocation = fn();
+ });
+
+ afterEach(() => {
+ currentWindowLocation = origWindowLocation;
+ });
+};
+
+/**
+ * Create an object with the location interface but `jest.fn()` implementations.
+ */
+export const createWindowLocationSpy = () => {
+ return {
+ assign: jest.fn(),
+ reload: jest.fn(),
+ replace: jest.fn(),
+ toString: jest.fn(),
+ };
+};
+
+/**
+ * Before each test, overwrite `window.location` with a spy implementation.
+ */
+export const useMockLocationHelper = () => useMockLocation(createWindowLocationSpy);
diff --git a/spec/frontend/__helpers__/set_timeout_promise_helper.js b/spec/frontend/__helpers__/set_timeout_promise_helper.js
new file mode 100644
index 00000000000..afd18d92d15
--- /dev/null
+++ b/spec/frontend/__helpers__/set_timeout_promise_helper.js
@@ -0,0 +1,4 @@
+export default (time = 0) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, time);
+ });
diff --git a/spec/frontend/__helpers__/set_window_location_helper.js b/spec/frontend/__helpers__/set_window_location_helper.js
new file mode 100644
index 00000000000..a94e73762c9
--- /dev/null
+++ b/spec/frontend/__helpers__/set_window_location_helper.js
@@ -0,0 +1,40 @@
+/**
+ * setWindowLocation allows for setting `window.location`
+ * (doing so directly is causing an error in jsdom)
+ *
+ * Example usage:
+ * assert(window.location.hash === undefined);
+ * setWindowLocation('http://example.com#foo')
+ * assert(window.location.hash === '#foo');
+ *
+ * More information:
+ * https://github.com/facebook/jest/issues/890
+ *
+ * @param url
+ */
+export default function setWindowLocation(url) {
+ const parsedUrl = new URL(url);
+
+ const newLocationValue = [
+ 'hash',
+ 'host',
+ 'hostname',
+ 'href',
+ 'origin',
+ 'pathname',
+ 'port',
+ 'protocol',
+ 'search',
+ ].reduce(
+ (location, prop) => ({
+ ...location,
+ [prop]: parsedUrl[prop],
+ }),
+ {},
+ );
+
+ Object.defineProperty(window, 'location', {
+ value: newLocationValue,
+ writable: true,
+ });
+}
diff --git a/spec/frontend/__helpers__/set_window_location_helper_spec.js b/spec/frontend/__helpers__/set_window_location_helper_spec.js
new file mode 100644
index 00000000000..98f26854822
--- /dev/null
+++ b/spec/frontend/__helpers__/set_window_location_helper_spec.js
@@ -0,0 +1,40 @@
+import setWindowLocation from './set_window_location_helper';
+
+describe('setWindowLocation', () => {
+ const originalLocation = window.location;
+
+ afterEach(() => {
+ window.location = originalLocation;
+ });
+
+ it.each`
+ url | property | value
+ ${'https://gitlab.com#foo'} | ${'hash'} | ${'#foo'}
+ ${'http://gitlab.com'} | ${'host'} | ${'gitlab.com'}
+ ${'http://gitlab.org'} | ${'hostname'} | ${'gitlab.org'}
+ ${'http://gitlab.org/foo#bar'} | ${'href'} | ${'http://gitlab.org/foo#bar'}
+ ${'http://gitlab.com'} | ${'origin'} | ${'http://gitlab.com'}
+ ${'http://gitlab.com/foo/bar/baz'} | ${'pathname'} | ${'/foo/bar/baz'}
+ ${'https://gitlab.com'} | ${'protocol'} | ${'https:'}
+ ${'http://gitlab.com#foo'} | ${'protocol'} | ${'http:'}
+ ${'http://gitlab.com:8080'} | ${'port'} | ${'8080'}
+ ${'http://gitlab.com?foo=bar&bar=foo'} | ${'search'} | ${'?foo=bar&bar=foo'}
+ `(
+ 'sets "window.location.$property" to be "$value" when called with: "$url"',
+ ({ url, property, value }) => {
+ expect(window.location).toBe(originalLocation);
+
+ setWindowLocation(url);
+
+ expect(window.location[property]).toBe(value);
+ },
+ );
+
+ it.each([null, 1, undefined, false, '', 'gitlab.com'])(
+ 'throws an error when called with an invalid url: "%s"',
+ (invalidUrl) => {
+ expect(() => setWindowLocation(invalidUrl)).toThrow(/Invalid URL/);
+ expect(window.location).toBe(originalLocation);
+ },
+ );
+});
diff --git a/spec/frontend/__helpers__/stub_children.js b/spec/frontend/__helpers__/stub_children.js
new file mode 100644
index 00000000000..0711563699f
--- /dev/null
+++ b/spec/frontend/__helpers__/stub_children.js
@@ -0,0 +1,3 @@
+export default function stubChildren(Component) {
+ return Object.fromEntries(Object.keys(Component.components).map((c) => [c, true]));
+}
diff --git a/spec/frontend/__helpers__/stub_component.js b/spec/frontend/__helpers__/stub_component.js
new file mode 100644
index 00000000000..45550450517
--- /dev/null
+++ b/spec/frontend/__helpers__/stub_component.js
@@ -0,0 +1,12 @@
+export function stubComponent(Component, options = {}) {
+ return {
+ props: Component.props,
+ model: Component.model,
+ // Do not render any slots/scoped slots except default
+ // This differs from VTU behavior which renders all slots
+ template: '<div><slot></slot></div>',
+ // allows wrapper.find(Component) to work for stub
+ $_vueTestUtils_original: Component,
+ ...options,
+ };
+}
diff --git a/spec/frontend/__helpers__/stub_transition.js b/spec/frontend/__helpers__/stub_transition.js
new file mode 100644
index 00000000000..9cddb432a63
--- /dev/null
+++ b/spec/frontend/__helpers__/stub_transition.js
@@ -0,0 +1,8 @@
+export function stubTransition() {
+ return {
+ render() {
+ // eslint-disable-next-line no-underscore-dangle
+ return this.$options._renderChildren;
+ },
+ };
+}
diff --git a/spec/frontend/__helpers__/test_constants.js b/spec/frontend/__helpers__/test_constants.js
new file mode 100644
index 00000000000..69b78f556aa
--- /dev/null
+++ b/spec/frontend/__helpers__/test_constants.js
@@ -0,0 +1,19 @@
+const FIXTURES_PATH = `/fixtures`;
+const TEST_HOST = 'http://test.host';
+
+const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/static/images/one_white_pixel.png`;
+
+const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/green_box.png`;
+const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/static/images/red_box.png`;
+
+// NOTE: module.exports is needed so that this file can be used
+// by environment.js
+//
+// eslint-disable-next-line import/no-commonjs
+module.exports = {
+ FIXTURES_PATH,
+ TEST_HOST,
+ DUMMY_IMAGE_URL,
+ GREEN_BOX_IMAGE_URL,
+ RED_BOX_IMAGE_URL,
+};
diff --git a/spec/frontend/__helpers__/text_helper.js b/spec/frontend/__helpers__/text_helper.js
new file mode 100644
index 00000000000..164f18faadc
--- /dev/null
+++ b/spec/frontend/__helpers__/text_helper.js
@@ -0,0 +1,18 @@
+/**
+ * Replaces line break with an empty space
+ * @param {*} data
+ */
+export const removeBreakLine = (data) => data.replace(/\r?\n|\r/g, ' ');
+
+/**
+ * Removes line breaks, spaces and trims the given text
+ * @param {String} str
+ * @returns {String}
+ */
+export const trimText = (str) =>
+ str
+ .replace(/\r?\n|\r/g, '')
+ .replace(/\s\s+/g, ' ')
+ .trim();
+
+export const removeWhitespace = (str) => str.replace(/\s\s+/g, ' ');
diff --git a/spec/frontend/__helpers__/timeout.js b/spec/frontend/__helpers__/timeout.js
new file mode 100644
index 00000000000..8688625a95e
--- /dev/null
+++ b/spec/frontend/__helpers__/timeout.js
@@ -0,0 +1,59 @@
+const NS_PER_SEC = 1e9;
+const NS_PER_MS = 1e6;
+const IS_DEBUGGING = process.execArgv.join(' ').includes('--inspect-brk');
+
+let testTimeoutNS;
+
+export const setTestTimeout = (newTimeoutMS) => {
+ const newTimeoutNS = newTimeoutMS * NS_PER_MS;
+ // never accept a smaller timeout than the default
+ if (newTimeoutNS < testTimeoutNS) {
+ return;
+ }
+
+ testTimeoutNS = newTimeoutNS;
+ jest.setTimeout(newTimeoutMS);
+};
+
+// Allows slow tests to set their own timeout.
+// Useful for tests with jQuery, which is very slow in big DOMs.
+let temporaryTimeoutNS = null;
+export const setTestTimeoutOnce = (newTimeoutMS) => {
+ const newTimeoutNS = newTimeoutMS * NS_PER_MS;
+ // never accept a smaller timeout than the default
+ if (newTimeoutNS < testTimeoutNS) {
+ return;
+ }
+
+ temporaryTimeoutNS = newTimeoutNS;
+};
+
+export const initializeTestTimeout = (defaultTimeoutMS) => {
+ setTestTimeout(defaultTimeoutMS);
+
+ let testStartTime;
+
+ // https://github.com/facebook/jest/issues/6947
+ beforeEach(() => {
+ testStartTime = process.hrtime();
+ });
+
+ afterEach(() => {
+ let timeoutNS = testTimeoutNS;
+ if (Number.isFinite(temporaryTimeoutNS)) {
+ timeoutNS = temporaryTimeoutNS;
+ temporaryTimeoutNS = null;
+ }
+
+ const [seconds, remainingNs] = process.hrtime(testStartTime);
+ const elapsedNS = seconds * NS_PER_SEC + remainingNs;
+
+ // Disable the timeout error when debugging. It is meaningless because
+ // debugging always takes longer than the test timeout.
+ if (elapsedNS > timeoutNS && !IS_DEBUGGING) {
+ throw new Error(
+ `Test took too long (${elapsedNS / NS_PER_MS}ms > ${timeoutNS / NS_PER_MS}ms)!`,
+ );
+ }
+ });
+};
diff --git a/spec/frontend/__helpers__/tracking_helper.js b/spec/frontend/__helpers__/tracking_helper.js
new file mode 100644
index 00000000000..08ba5a6628e
--- /dev/null
+++ b/spec/frontend/__helpers__/tracking_helper.js
@@ -0,0 +1,25 @@
+import Tracking from '~/tracking';
+
+export default Tracking;
+
+let document;
+let handlers;
+
+export function mockTracking(category = '_category_', documentOverride, spyMethod) {
+ document = documentOverride || window.document;
+ window.snowplow = () => {};
+ handlers = Tracking.bindDocument(category, document);
+ return spyMethod ? spyMethod(Tracking, 'event') : null;
+}
+
+export function unmockTracking() {
+ window.snowplow = undefined;
+ handlers.forEach((event) => document.removeEventListener(event.name, event.func));
+}
+
+export function triggerEvent(selectorOrEl, eventName = 'click') {
+ const event = new Event(eventName, { bubbles: true });
+ const el = typeof selectorOrEl === 'string' ? document.querySelector(selectorOrEl) : selectorOrEl;
+
+ el.dispatchEvent(event);
+}
diff --git a/spec/frontend/__helpers__/user_mock_data_helper.js b/spec/frontend/__helpers__/user_mock_data_helper.js
new file mode 100644
index 00000000000..db747283d9e
--- /dev/null
+++ b/spec/frontend/__helpers__/user_mock_data_helper.js
@@ -0,0 +1,29 @@
+let id = 1;
+
+// Code taken from: https://gist.github.com/6174/6062387
+const getRandomString = () =>
+ Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
+
+const getRandomUrl = () => `https://${getRandomString()}.com/${getRandomString()}`;
+
+export default {
+ createNumberRandomUsers(numberUsers) {
+ const users = [];
+ for (let i = 0; i < numberUsers; i += 1) {
+ users.push({
+ avatar_url: getRandomUrl(),
+ id: id + 1,
+ name: getRandomString(),
+ username: getRandomString(),
+ user_path: getRandomUrl(),
+ });
+
+ id += 1;
+ }
+ return users;
+ },
+
+ createRandomUser() {
+ return this.createNumberRandomUsers(1)[0];
+ },
+};
diff --git a/spec/frontend/__helpers__/vue_mock_directive.js b/spec/frontend/__helpers__/vue_mock_directive.js
new file mode 100644
index 00000000000..e952f258c4d
--- /dev/null
+++ b/spec/frontend/__helpers__/vue_mock_directive.js
@@ -0,0 +1,25 @@
+export const getKey = (name) => `$_gl_jest_${name}`;
+
+export const getBinding = (el, name) => el[getKey(name)];
+
+const writeBindingToElement = (el, { name, value, arg, modifiers }) => {
+ el[getKey(name)] = {
+ value,
+ arg,
+ modifiers,
+ };
+};
+
+export const createMockDirective = () => ({
+ bind(el, binding) {
+ writeBindingToElement(el, binding);
+ },
+
+ update(el, binding) {
+ writeBindingToElement(el, binding);
+ },
+
+ unbind(el, { name }) {
+ delete el[getKey(name)];
+ },
+});
diff --git a/spec/frontend/__helpers__/vue_mount_component_helper.js b/spec/frontend/__helpers__/vue_mount_component_helper.js
new file mode 100644
index 00000000000..615ff69a01c
--- /dev/null
+++ b/spec/frontend/__helpers__/vue_mount_component_helper.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
+const mountComponent = (Component, props = {}, el = null) =>
+ new Component({
+ propsData: props,
+ }).$mount(el);
+
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
+export const createComponentWithStore = (Component, store, propsData = {}) =>
+ new Component({
+ store,
+ propsData,
+ });
+
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
+export const mountComponentWithStore = (Component, { el, props, store }) =>
+ new Component({
+ store,
+ propsData: props || {},
+ }).$mount(el);
+
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
+export const mountComponentWithSlots = (Component, { props, slots }) => {
+ const component = new Component({
+ propsData: props || {},
+ });
+
+ component.$slots = slots;
+
+ return component.$mount();
+};
+
+/**
+ * Mount a component with the given render method.
+ *
+ * -----------------------------
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ * -----------------------------
+ *
+ * This helps with inserting slots that need to be compiled.
+ */
+export const mountComponentWithRender = (render, el = null) =>
+ mountComponent(Vue.extend({ render }), {}, el);
+
+/**
+ * Deprecated. Please do not use.
+ * Please see https://gitlab.com/groups/gitlab-org/-/epics/2445
+ */
+export default mountComponent;
diff --git a/spec/frontend/__helpers__/vue_test_utils_helper.js b/spec/frontend/__helpers__/vue_test_utils_helper.js
new file mode 100644
index 00000000000..ffccfb249c2
--- /dev/null
+++ b/spec/frontend/__helpers__/vue_test_utils_helper.js
@@ -0,0 +1,53 @@
+import { isArray } from 'lodash';
+
+const vNodeContainsText = (vnode, text) =>
+ (vnode.text && vnode.text.includes(text)) ||
+ (vnode.children && vnode.children.filter((child) => vNodeContainsText(child, text)).length);
+
+/**
+ * Determines whether a `shallowMount` Wrapper contains text
+ * within one of it's slots. This will also work on Wrappers
+ * acquired with `find()`, but only if it's parent Wrapper
+ * was shallowMounted.
+ * NOTE: Prefer checking the rendered output of a component
+ * wherever possible using something like `text()` instead.
+ * @param {Wrapper} shallowWrapper - Vue test utils wrapper (shallowMounted)
+ * @param {String} slotName
+ * @param {String} text
+ */
+export const shallowWrapperContainsSlotText = (shallowWrapper, slotName, text) =>
+ Boolean(
+ shallowWrapper.vm.$slots[slotName].filter((vnode) => vNodeContainsText(vnode, text)).length,
+ );
+
+/**
+ * Returns a promise that waits for a mutation to be fired before resolving
+ * NOTE: There's no reject action here so it will hang if it waits for a mutation that won't happen.
+ * @param {Object} store - The Vue store that contains the mutations
+ * @param {String} expectedMutationType - The Mutation to wait for
+ */
+export const waitForMutation = (store, expectedMutationType) =>
+ new Promise((resolve) => {
+ const unsubscribe = store.subscribe((mutation) => {
+ if (mutation.type === expectedMutationType) {
+ unsubscribe();
+ resolve();
+ }
+ });
+ });
+
+export const extendedWrapper = (wrapper) => {
+ if (isArray(wrapper) || !wrapper?.find) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ '[vue-test-utils-helper]: you are trying to extend an object that is not a VueWrapper.',
+ );
+ return wrapper;
+ }
+
+ return Object.defineProperty(wrapper, 'findByTestId', {
+ value(id) {
+ return this.find(`[data-testid="${id}"]`);
+ },
+ });
+};
diff --git a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js
new file mode 100644
index 00000000000..31c4ccd5dbb
--- /dev/null
+++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js
@@ -0,0 +1,92 @@
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper, shallowWrapperContainsSlotText } from './vue_test_utils_helper';
+
+describe('Vue test utils helpers', () => {
+ describe('shallowWrapperContainsSlotText', () => {
+ const mockText = 'text';
+ const mockSlot = `<div>${mockText}</div>`;
+ let mockComponent;
+
+ beforeEach(() => {
+ mockComponent = shallowMount(
+ {
+ render(h) {
+ h(`<div>mockedComponent</div>`);
+ },
+ },
+ {
+ slots: {
+ default: mockText,
+ namedSlot: mockSlot,
+ },
+ },
+ );
+ });
+
+ it('finds text within shallowWrapper default slot', () => {
+ expect(shallowWrapperContainsSlotText(mockComponent, 'default', mockText)).toBe(true);
+ });
+
+ it('finds text within shallowWrapper named slot', () => {
+ expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', mockText)).toBe(true);
+ });
+
+ it('returns false when text is not present', () => {
+ const searchText = 'absent';
+
+ expect(shallowWrapperContainsSlotText(mockComponent, 'default', searchText)).toBe(false);
+ expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', searchText)).toBe(false);
+ });
+
+ it('searches with case-sensitivity', () => {
+ const searchText = mockText.toUpperCase();
+
+ expect(shallowWrapperContainsSlotText(mockComponent, 'default', searchText)).toBe(false);
+ expect(shallowWrapperContainsSlotText(mockComponent, 'namedSlot', searchText)).toBe(false);
+ });
+ });
+
+ describe('extendedWrapper', () => {
+ describe('when an invalid wrapper is provided', () => {
+ beforeEach(() => {
+ // eslint-disable-next-line no-console
+ console.warn = jest.fn();
+ });
+
+ it.each`
+ wrapper
+ ${{}}
+ ${[]}
+ ${null}
+ ${undefined}
+ ${1}
+ ${''}
+ `('should warn with an error when the wrapper is $wrapper', ({ wrapper }) => {
+ extendedWrapper(wrapper);
+ /* eslint-disable no-console */
+ expect(console.warn).toHaveBeenCalled();
+ expect(console.warn).toHaveBeenCalledWith(
+ '[vue-test-utils-helper]: you are trying to extend an object that is not a VueWrapper.',
+ );
+ /* eslint-enable no-console */
+ });
+ });
+
+ describe('findByTestId', () => {
+ const testId = 'a-component';
+ let mockComponent;
+
+ beforeEach(() => {
+ mockComponent = extendedWrapper(
+ shallowMount({
+ template: `<div data-testid="${testId}"></div>`,
+ }),
+ );
+ });
+
+ it('should find the component by test id', () => {
+ expect(mockComponent.findByTestId(testId).exists()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/__helpers__/vuex_action_helper.js b/spec/frontend/__helpers__/vuex_action_helper.js
new file mode 100644
index 00000000000..e482a8fbc71
--- /dev/null
+++ b/spec/frontend/__helpers__/vuex_action_helper.js
@@ -0,0 +1,128 @@
+const noop = () => {};
+
+/**
+ * Helper for testing action with expected mutations inspired in
+ * https://vuex.vuejs.org/en/testing.html
+ *
+ * @param {(Function|Object)} action to be tested, or object of named parameters
+ * @param {Object} payload will be provided to the action
+ * @param {Object} state will be provided to the action
+ * @param {Array} [expectedMutations=[]] mutations expected to be committed
+ * @param {Array} [expectedActions=[]] actions expected to be dispatched
+ * @param {Function} [done=noop] to be executed after the tests
+ * @return {Promise}
+ *
+ * @example
+ * testAction(
+ * actions.actionName, // action
+ * { }, // mocked payload
+ * state, //state
+ * // expected mutations
+ * [
+ * { type: types.MUTATION}
+ * { type: types.MUTATION_1, payload: expect.any(Number)}
+ * ],
+ * // expected actions
+ * [
+ * { type: 'actionName', payload: {param: 'foobar'}},
+ * { type: 'actionName1'}
+ * ]
+ * done,
+ * );
+ *
+ * @example
+ * testAction(
+ * actions.actionName, // action
+ * { }, // mocked payload
+ * state, //state
+ * [ { type: types.MUTATION} ], // expected mutations
+ * [], // expected actions
+ * ).then(done)
+ * .catch(done.fail);
+ *
+ * @example
+ * await testAction({
+ * action: actions.actionName,
+ * payload: { deleteListId: 1 },
+ * state: { lists: [1, 2, 3] },
+ * expectedMutations: [ { type: types.MUTATION} ],
+ * expectedActions: [],
+ * })
+ */
+export default (
+ actionArg,
+ payloadArg,
+ stateArg,
+ expectedMutationsArg = [],
+ expectedActionsArg = [],
+ doneArg = noop,
+) => {
+ let action = actionArg;
+ let payload = payloadArg;
+ let state = stateArg;
+ let expectedMutations = expectedMutationsArg;
+ let expectedActions = expectedActionsArg;
+ let done = doneArg;
+
+ if (typeof actionArg !== 'function') {
+ ({
+ action,
+ payload,
+ state,
+ expectedMutations = [],
+ expectedActions = [],
+ done = noop,
+ } = actionArg);
+ }
+
+ const mutations = [];
+ const actions = [];
+
+ // mock commit
+ const commit = (type, mutationPayload) => {
+ const mutation = { type };
+
+ if (typeof mutationPayload !== 'undefined') {
+ mutation.payload = mutationPayload;
+ }
+
+ mutations.push(mutation);
+ };
+
+ // mock dispatch
+ const dispatch = (type, actionPayload) => {
+ const dispatchedAction = { type };
+
+ if (typeof actionPayload !== 'undefined') {
+ dispatchedAction.payload = actionPayload;
+ }
+
+ actions.push(dispatchedAction);
+ };
+
+ const validateResults = () => {
+ expect({
+ mutations,
+ actions,
+ }).toEqual({
+ mutations: expectedMutations,
+ actions: expectedActions,
+ });
+ done();
+ };
+
+ const result = action(
+ { commit, state, dispatch, rootState: state, rootGetters: state, getters: state },
+ payload,
+ );
+
+ return (result || new Promise((resolve) => setImmediate(resolve)))
+ .catch((error) => {
+ validateResults();
+ throw error;
+ })
+ .then((data) => {
+ validateResults();
+ return data;
+ });
+};
diff --git a/spec/frontend/__helpers__/vuex_action_helper_spec.js b/spec/frontend/__helpers__/vuex_action_helper_spec.js
new file mode 100644
index 00000000000..b4f5a291774
--- /dev/null
+++ b/spec/frontend/__helpers__/vuex_action_helper_spec.js
@@ -0,0 +1,174 @@
+import MockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'helpers/test_constants';
+import axios from '~/lib/utils/axios_utils';
+import testActionFn from './vuex_action_helper';
+
+const testActionFnWithOptionsArg = (...args) => {
+ const [action, payload, state, expectedMutations, expectedActions, done] = args;
+ return testActionFn({ action, payload, state, expectedMutations, expectedActions, done });
+};
+
+describe.each([testActionFn, testActionFnWithOptionsArg])(
+ 'VueX test helper (testAction)',
+ (testAction) => {
+ let originalExpect;
+ let assertion;
+ let mock;
+ const noop = () => {};
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ /**
+ * In order to test the helper properly, we need to overwrite the Jest
+ * `expect` helper. We test that the testAction helper properly passes the
+ * dispatched actions/committed mutations to the Jest helper.
+ */
+ originalExpect = expect;
+ assertion = null;
+ global.expect = (actual) => ({
+ toEqual: () => {
+ originalExpect(actual).toEqual(assertion);
+ },
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ global.expect = originalExpect;
+ });
+
+ it('properly passes state and payload to action', () => {
+ const exampleState = { FOO: 12, BAR: 3 };
+ const examplePayload = { BAZ: 73, BIZ: 55 };
+
+ const action = ({ state }, payload) => {
+ originalExpect(state).toEqual(exampleState);
+ originalExpect(payload).toEqual(examplePayload);
+ };
+
+ assertion = { mutations: [], actions: [] };
+
+ testAction(action, examplePayload, exampleState);
+ });
+
+ describe('given a sync action', () => {
+ it('mocks committing mutations', () => {
+ const action = ({ commit }) => {
+ commit('MUTATION');
+ };
+
+ assertion = { mutations: [{ type: 'MUTATION' }], actions: [] };
+
+ testAction(action, null, {}, assertion.mutations, assertion.actions, noop);
+ });
+
+ it('mocks dispatching actions', () => {
+ const action = ({ dispatch }) => {
+ dispatch('ACTION');
+ };
+
+ assertion = { actions: [{ type: 'ACTION' }], mutations: [] };
+
+ testAction(action, null, {}, assertion.mutations, assertion.actions, noop);
+ });
+
+ it('works with done callback once finished', (done) => {
+ assertion = { mutations: [], actions: [] };
+
+ testAction(noop, null, {}, assertion.mutations, assertion.actions, done);
+ });
+
+ it('returns a promise', (done) => {
+ assertion = { mutations: [], actions: [] };
+
+ testAction(noop, null, {}, assertion.mutations, assertion.actions)
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('given an async action (returning a promise)', () => {
+ let lastError;
+ const data = { FOO: 'BAR' };
+
+ const asyncAction = ({ commit, dispatch }) => {
+ dispatch('ACTION');
+
+ return axios
+ .get(TEST_HOST)
+ .catch((error) => {
+ commit('ERROR');
+ lastError = error;
+ throw error;
+ })
+ .then(() => {
+ commit('SUCCESS');
+ return data;
+ });
+ };
+
+ beforeEach(() => {
+ lastError = null;
+ });
+
+ it('works with done callback once finished', (done) => {
+ mock.onGet(TEST_HOST).replyOnce(200, 42);
+
+ assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] };
+
+ testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done);
+ });
+
+ it('returns original data of successful promise while checking actions/mutations', (done) => {
+ mock.onGet(TEST_HOST).replyOnce(200, 42);
+
+ assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] };
+
+ testAction(asyncAction, null, {}, assertion.mutations, assertion.actions)
+ .then((res) => {
+ originalExpect(res).toEqual(data);
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns original error of rejected promise while checking actions/mutations', (done) => {
+ mock.onGet(TEST_HOST).replyOnce(500, '');
+
+ assertion = { mutations: [{ type: 'ERROR' }], actions: [{ type: 'ACTION' }] };
+
+ testAction(asyncAction, null, {}, assertion.mutations, assertion.actions)
+ .then(done.fail)
+ .catch((error) => {
+ originalExpect(error).toBe(lastError);
+ done();
+ });
+ });
+ });
+
+ it('works with async actions not returning promises', (done) => {
+ const data = { FOO: 'BAR' };
+
+ const asyncAction = ({ commit, dispatch }) => {
+ dispatch('ACTION');
+
+ axios
+ .get(TEST_HOST)
+ .then(() => {
+ commit('SUCCESS');
+ return data;
+ })
+ .catch((error) => {
+ commit('ERROR');
+ throw error;
+ });
+ };
+
+ mock.onGet(TEST_HOST).replyOnce(200, 42);
+
+ assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] };
+
+ testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done);
+ });
+ },
+);
diff --git a/spec/frontend/__helpers__/wait_for_promises.js b/spec/frontend/__helpers__/wait_for_promises.js
new file mode 100644
index 00000000000..2fd1cc6ba0d
--- /dev/null
+++ b/spec/frontend/__helpers__/wait_for_promises.js
@@ -0,0 +1 @@
+export default () => new Promise((resolve) => requestAnimationFrame(resolve));
diff --git a/spec/frontend/__helpers__/wait_for_text.js b/spec/frontend/__helpers__/wait_for_text.js
new file mode 100644
index 00000000000..6bed8a90a98
--- /dev/null
+++ b/spec/frontend/__helpers__/wait_for_text.js
@@ -0,0 +1,3 @@
+import { findByText } from '@testing-library/dom';
+
+export const waitForText = async (text, container = document) => findByText(container, text);
diff --git a/spec/frontend/__helpers__/wait_using_real_timer.js b/spec/frontend/__helpers__/wait_using_real_timer.js
new file mode 100644
index 00000000000..110d5f46c08
--- /dev/null
+++ b/spec/frontend/__helpers__/wait_using_real_timer.js
@@ -0,0 +1,7 @@
+/* useful for timing promises when jest fakeTimers are not reliable enough */
+export default (timeout) =>
+ new Promise((resolve) => {
+ jest.useRealTimers();
+ setTimeout(resolve, timeout);
+ jest.useFakeTimers();
+ });
diff --git a/spec/frontend/__helpers__/web_worker_mock.js b/spec/frontend/__helpers__/web_worker_mock.js
new file mode 100644
index 00000000000..2b4a391e1d2
--- /dev/null
+++ b/spec/frontend/__helpers__/web_worker_mock.js
@@ -0,0 +1,10 @@
+/* eslint-disable class-methods-use-this */
+export default class WebWorkerMock {
+ addEventListener() {}
+
+ removeEventListener() {}
+
+ terminate() {}
+
+ postMessage() {}
+}