diff options
author | Robert Speicher <rspeicher@gmail.com> | 2021-01-20 13:34:23 -0600 |
---|---|---|
committer | Robert Speicher <rspeicher@gmail.com> | 2021-01-20 13:34:23 -0600 |
commit | 6438df3a1e0fb944485cebf07976160184697d72 (patch) | |
tree | 00b09bfd170e77ae9391b1a2f5a93ef6839f2597 /spec/frontend/__helpers__ | |
parent | 42bcd54d971da7ef2854b896a7b34f4ef8601067 (diff) | |
download | gitlab-ce-6438df3a1e0fb944485cebf07976160184697d72.tar.gz |
Add latest changes from gitlab-org/gitlab@13-8-stable-eev13.8.0-rc42
Diffstat (limited to 'spec/frontend/__helpers__')
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() {} +} |