diff options
Diffstat (limited to 'spec/frontend')
32 files changed, 2494 insertions, 220 deletions
diff --git a/spec/frontend/activities_spec.js b/spec/frontend/activities_spec.js new file mode 100644 index 00000000000..d14be3a1f26 --- /dev/null +++ b/spec/frontend/activities_spec.js @@ -0,0 +1,70 @@ +/* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow */ + +import $ from 'jquery'; +import Activities from '~/activities'; +import Pager from '~/pager'; + +describe('Activities', () => { + window.gon || (window.gon = {}); + const fixtureTemplate = 'static/event_filter.html'; + const filters = [ + { + id: 'all', + }, + { + id: 'push', + name: 'push events', + }, + { + id: 'merged', + name: 'merge events', + }, + { + id: 'comments', + }, + { + id: 'team', + }, + ]; + + function getEventName(index) { + const filter = filters[index]; + return filter.hasOwnProperty('name') ? filter.name : filter.id; + } + + function getSelector(index) { + const filter = filters[index]; + return `#${filter.id}_event_filter`; + } + + beforeEach(() => { + loadFixtures(fixtureTemplate); + jest.spyOn(Pager, 'init').mockImplementation(() => {}); + new Activities(); + }); + + for (let i = 0; i < filters.length; i += 1) { + (i => { + describe(`when selecting ${getEventName(i)}`, () => { + beforeEach(() => { + $(getSelector(i)).click(); + }); + + for (let x = 0; x < filters.length; x += 1) { + (x => { + const shouldHighlight = i === x; + const testName = shouldHighlight ? 'should highlight' : 'should not highlight'; + + it(`${testName} ${getEventName(x)}`, () => { + expect( + $(getSelector(x)) + .parent() + .hasClass('active'), + ).toEqual(shouldHighlight); + }); + })(x); + } + }); + })(i); + } +}); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js new file mode 100644 index 00000000000..6010488d9e0 --- /dev/null +++ b/spec/frontend/api_spec.js @@ -0,0 +1,477 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import Api from '~/api'; + +describe('Api', () => { + const dummyApiVersion = 'v3000'; + const dummyUrlRoot = '/gitlab'; + const dummyGon = { + api_version: dummyApiVersion, + relative_url_root: dummyUrlRoot, + }; + let originalGon; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + originalGon = window.gon; + window.gon = Object.assign({}, dummyGon); + }); + + afterEach(() => { + mock.restore(); + window.gon = originalGon; + }); + + describe('buildUrl', () => { + it('adds URL root and fills in API version', () => { + const input = '/api/:version/foo/bar'; + const expectedOutput = `${dummyUrlRoot}/api/${dummyApiVersion}/foo/bar`; + + const builtUrl = Api.buildUrl(input); + + expect(builtUrl).toEqual(expectedOutput); + }); + + [null, '', '/'].forEach(root => { + it(`works when relative_url_root is ${root}`, () => { + window.gon.relative_url_root = root; + const input = '/api/:version/foo/bar'; + const expectedOutput = `/api/${dummyApiVersion}/foo/bar`; + + const builtUrl = Api.buildUrl(input); + + expect(builtUrl).toEqual(expectedOutput); + }); + }); + }); + + describe('group', () => { + it('fetches a group', done => { + const groupId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}`; + mock.onGet(expectedUrl).reply(200, { + name: 'test', + }); + + Api.group(groupId, response => { + expect(response.name).toBe('test'); + done(); + }); + }); + }); + + describe('groupMembers', () => { + it('fetches group members', done => { + const groupId = '54321'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`; + const expectedData = [{ id: 7 }]; + mock.onGet(expectedUrl).reply(200, expectedData); + + Api.groupMembers(groupId) + .then(({ data }) => { + expect(data).toEqual(expectedData); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('groups', () => { + it('fetches groups', done => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.groups(query, options, response => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + done(); + }); + }); + }); + + describe('namespaces', () => { + it('fetches namespaces', done => { + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.namespaces(query, response => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + done(); + }); + }); + }); + + describe('projects', () => { + it('fetches projects with membership when logged in', done => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; + window.gon.current_user_id = 1; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.projects(query, options, response => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + done(); + }); + }); + + it('fetches projects without membership when not logged in', done => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.projects(query, options, response => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + done(); + }); + }); + }); + + describe('projectMergeRequests', () => { + const projectPath = 'abc'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests`; + + it('fetches all merge requests for a project', done => { + const mockData = [{ source_branch: 'foo' }, { source_branch: 'bar' }]; + mock.onGet(expectedUrl).reply(200, mockData); + Api.projectMergeRequests(projectPath) + .then(({ data }) => { + expect(data.length).toEqual(2); + expect(data[0].source_branch).toBe('foo'); + expect(data[1].source_branch).toBe('bar'); + }) + .then(done) + .catch(done.fail); + }); + + it('fetches merge requests filtered with passed params', done => { + const params = { + source_branch: 'bar', + }; + const mockData = [{ source_branch: 'bar' }]; + mock.onGet(expectedUrl, { params }).reply(200, mockData); + + Api.projectMergeRequests(projectPath, params) + .then(({ data }) => { + expect(data.length).toEqual(1); + expect(data[0].source_branch).toBe('bar'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('projectMergeRequest', () => { + it('fetches a merge request', done => { + const projectPath = 'abc'; + const mergeRequestId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}`; + mock.onGet(expectedUrl).reply(200, { + title: 'test', + }); + + Api.projectMergeRequest(projectPath, mergeRequestId) + .then(({ data }) => { + expect(data.title).toBe('test'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('projectMergeRequestChanges', () => { + it('fetches the changes of a merge request', done => { + const projectPath = 'abc'; + const mergeRequestId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/changes`; + mock.onGet(expectedUrl).reply(200, { + title: 'test', + }); + + Api.projectMergeRequestChanges(projectPath, mergeRequestId) + .then(({ data }) => { + expect(data.title).toBe('test'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('projectMergeRequestVersions', () => { + it('fetches the versions of a merge request', done => { + const projectPath = 'abc'; + const mergeRequestId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/versions`; + mock.onGet(expectedUrl).reply(200, [ + { + id: 123, + }, + ]); + + Api.projectMergeRequestVersions(projectPath, mergeRequestId) + .then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].id).toBe(123); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('projectRunners', () => { + it('fetches the runners of a project', done => { + const projectPath = 7; + const params = { scope: 'active' }; + const mockData = [{ id: 4 }]; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`; + mock.onGet(expectedUrl, { params }).reply(200, mockData); + + Api.projectRunners(projectPath, { params }) + .then(({ data }) => { + expect(data).toEqual(mockData); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('newLabel', () => { + it('creates a new label', done => { + const namespace = 'some namespace'; + const project = 'some project'; + const labelData = { some: 'data' }; + const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/-/labels`; + const expectedData = { + label: labelData, + }; + mock.onPost(expectedUrl).reply(config => { + expect(config.data).toBe(JSON.stringify(expectedData)); + + return [ + 200, + { + name: 'test', + }, + ]; + }); + + Api.newLabel(namespace, project, labelData, response => { + expect(response.name).toBe('test'); + done(); + }); + }); + + it('creates a group label', done => { + const namespace = 'group/subgroup'; + const labelData = { some: 'data' }; + const expectedUrl = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace); + const expectedData = { + label: labelData, + }; + mock.onPost(expectedUrl).reply(config => { + expect(config.data).toBe(JSON.stringify(expectedData)); + + return [ + 200, + { + name: 'test', + }, + ]; + }); + + Api.newLabel(namespace, undefined, labelData, response => { + expect(response.name).toBe('test'); + done(); + }); + }); + }); + + describe('groupProjects', () => { + it('fetches group projects', done => { + const groupId = '123456'; + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.groupProjects(groupId, query, {}, response => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + done(); + }); + }); + }); + + describe('issueTemplate', () => { + it('fetches an issue template', done => { + const namespace = 'some namespace'; + const project = 'some project'; + const templateKey = ' template #%?.key '; + const templateType = 'template type'; + const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}/${encodeURIComponent( + templateKey, + )}`; + mock.onGet(expectedUrl).reply(200, 'test'); + + Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => { + expect(response).toBe('test'); + done(); + }); + }); + }); + + describe('projectTemplates', () => { + it('fetches a list of templates', done => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses`; + + mock.onGet(expectedUrl).reply(200, 'test'); + + Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, response => { + expect(response).toBe('test'); + done(); + }); + }); + }); + + describe('projectTemplate', () => { + it('fetches a single template', done => { + const data = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses/test%20license`; + + mock.onGet(expectedUrl).reply(200, 'test'); + + Api.projectTemplate('gitlab-org/gitlab-ce', 'licenses', 'test license', data, response => { + expect(response).toBe('test'); + done(); + }); + }); + }); + + describe('users', () => { + it('fetches users', done => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.users(query, options) + .then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('user', () => { + it('fetches single user', done => { + const userId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`; + mock.onGet(expectedUrl).reply(200, { + name: 'testuser', + }); + + Api.user(userId) + .then(({ data }) => { + expect(data.name).toBe('testuser'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('user status', () => { + it('fetches single user status', done => { + const userId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`; + mock.onGet(expectedUrl).reply(200, { + message: 'testmessage', + }); + + Api.userStatus(userId) + .then(({ data }) => { + expect(data.message).toBe('testmessage'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('commitPipelines', () => { + it('fetches pipelines for a given commit', done => { + const projectId = 'example/foobar'; + const commitSha = 'abc123def'; + const expectedUrl = `${dummyUrlRoot}/${projectId}/commit/${commitSha}/pipelines`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.commitPipelines(projectId, commitSha) + .then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('createBranch', () => { + it('creates new branch', done => { + const ref = 'master'; + const branch = 'new-branch-name'; + const dummyProjectPath = 'gitlab-org/gitlab-ce'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( + dummyProjectPath, + )}/repository/branches`; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).replyOnce(200, { + name: branch, + }); + + Api.createBranch(dummyProjectPath, { ref, branch }) + .then(({ data }) => { + expect(data.name).toBe(branch); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch }); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/autosave_spec.js b/spec/frontend/autosave_spec.js new file mode 100644 index 00000000000..4d9c8f96d62 --- /dev/null +++ b/spec/frontend/autosave_spec.js @@ -0,0 +1,151 @@ +import $ from 'jquery'; +import Autosave from '~/autosave'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +describe('Autosave', () => { + useLocalStorageSpy(); + + let autosave; + const field = $('<textarea></textarea>'); + const key = 'key'; + + describe('class constructor', () => { + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true); + jest.spyOn(Autosave.prototype, 'restore').mockImplementation(() => {}); + }); + + it('should set .isLocalStorageAvailable', () => { + autosave = new Autosave(field, key); + + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(autosave.isLocalStorageAvailable).toBe(true); + }); + }); + + describe('restore', () => { + beforeEach(() => { + autosave = { + field, + key, + }; + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.restore.call(autosave); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + }); + + it('should call .getItem', () => { + Autosave.prototype.restore.call(autosave); + + expect(window.localStorage.getItem).toHaveBeenCalledWith(key); + }); + + it('triggers jquery event', () => { + jest.spyOn(autosave.field, 'trigger').mockImplementation(() => {}); + + Autosave.prototype.restore.call(autosave); + + expect(field.trigger).toHaveBeenCalled(); + }); + + it('triggers native event', done => { + autosave.field.get(0).addEventListener('change', () => { + done(); + }); + + Autosave.prototype.restore.call(autosave); + }); + }); + + describe('if field gets deleted from DOM', () => { + beforeEach(() => { + autosave.field = $('.not-a-real-element'); + }); + + it('does not trigger event', () => { + jest.spyOn(field, 'trigger'); + + expect(field.trigger).not.toHaveBeenCalled(); + }); + }); + }); + + describe('save', () => { + beforeEach(() => { + autosave = { reset: jest.fn() }; + autosave.field = field; + field.val('value'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.save.call(autosave); + }); + + it('should not call .setItem', () => { + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.save.call(autosave); + }); + + it('should call .setItem', () => { + expect(window.localStorage.setItem).toHaveBeenCalled(); + }); + }); + }); + + describe('reset', () => { + beforeEach(() => { + autosave = { + key, + }; + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.reset.call(autosave); + }); + + it('should not call .removeItem', () => { + expect(window.localStorage.removeItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.reset.call(autosave); + }); + + it('should call .removeItem', () => { + expect(window.localStorage.removeItem).toHaveBeenCalledWith(key); + }); + }); + }); +}); diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index 73897107f67..66b22fa2681 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -209,6 +209,22 @@ describe('Clusters', () => { expect(cluster.errorContainer.classList.contains('hidden')).toBeFalsy(); }); }); + + describe('when cluster is unreachable', () => { + it('should show the unreachable warning container', () => { + cluster.updateContainer(null, 'unreachable'); + + expect(cluster.unreachableContainer.classList.contains('hidden')).toBe(false); + }); + }); + + describe('when cluster has an authentication failure', () => { + it('should show the authentication failure warning container', () => { + cluster.updateContainer(null, 'authentication_failure'); + + expect(cluster.authenticationFailureContainer.classList.contains('hidden')).toBe(false); + }); + }); }); describe('installApplication', () => { diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index 8bcf02f0a34..221ebb143be 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -1,9 +1,11 @@ import Vue from 'vue'; import applications from '~/clusters/components/applications.vue'; import { CLUSTER_TYPE } from '~/clusters/constants'; -import eventHub from '~/clusters/event_hub'; import mountComponent from 'helpers/vue_mount_component_helper'; import { APPLICATIONS_MOCK_STATE } from '../services/mock_data'; +import eventHub from '~/clusters/event_hub'; +import { shallowMount } from '@vue/test-utils'; +import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; describe('Applications', () => { let vm; @@ -277,73 +279,48 @@ describe('Applications', () => { }); describe('Knative application', () => { - describe('when installed', () => { - describe('with ip address', () => { - const props = { - applications: { - ...APPLICATIONS_MOCK_STATE, - knative: { - title: 'Knative', - hostname: 'example.com', - status: 'installed', - externalIp: '1.1.1.1', - }, - }, - }; - it('renders ip address with a clipboard button', () => { - vm = mountComponent(Applications, props); + const propsData = { + applications: { + ...APPLICATIONS_MOCK_STATE, + knative: { + title: 'Knative', + hostname: 'example.com', + status: 'installed', + externalIp: '1.1.1.1', + installed: true, + }, + }, + }; + const newHostname = 'newhostname.com'; + let wrapper; + let knativeDomainEditor; - expect(vm.$el.querySelector('.js-knative-endpoint').value).toEqual('1.1.1.1'); - - expect( - vm.$el - .querySelector('.js-knative-endpoint-clipboard-btn') - .getAttribute('data-clipboard-text'), - ).toEqual('1.1.1.1'); - }); - - it('renders domain & allows editing', () => { - expect(vm.$el.querySelector('.js-knative-domainname').value).toEqual('example.com'); - expect(vm.$el.querySelector('.js-knative-domainname').getAttribute('readonly')).toBe( - null, - ); - }); - - it('renders an update/save Knative domain button', () => { - expect(vm.$el.querySelector('.js-knative-save-domain-button')).not.toBe(null); - }); + beforeEach(() => { + wrapper = shallowMount(Applications, { propsData }); + jest.spyOn(eventHub, '$emit'); - it('emits event when clicking Save changes button', () => { - jest.spyOn(eventHub, '$emit'); - vm = mountComponent(Applications, props); + knativeDomainEditor = wrapper.find(KnativeDomainEditor); + }); - const saveButton = vm.$el.querySelector('.js-knative-save-domain-button'); + afterEach(() => { + wrapper.destroy(); + }); - saveButton.click(); + it('emits saveKnativeDomain event when knative domain editor emits save event', () => { + knativeDomainEditor.vm.$emit('save', newHostname); - expect(eventHub.$emit).toHaveBeenCalledWith('saveKnativeDomain', { - id: 'knative', - params: { hostname: 'example.com' }, - }); - }); + expect(eventHub.$emit).toHaveBeenCalledWith('saveKnativeDomain', { + id: 'knative', + params: { hostname: newHostname }, }); + }); - describe('without ip address', () => { - it('renders an input text with a loading icon and an alert text', () => { - vm = mountComponent(Applications, { - applications: { - ...APPLICATIONS_MOCK_STATE, - knative: { - title: 'Knative', - hostname: 'example.com', - status: 'installed', - }, - }, - }); + it('emits setKnativeHostname event when knative domain editor emits change event', () => { + wrapper.find(KnativeDomainEditor).vm.$emit('set', newHostname); - expect(vm.$el.querySelector('.js-knative-ip-loading-icon')).not.toBe(null); - expect(vm.$el.querySelector('.js-no-knative-endpoint-message')).not.toBe(null); - }); + expect(eventHub.$emit).toHaveBeenCalledWith('setKnativeHostname', { + id: 'knative', + hostname: newHostname, }); }); }); diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js new file mode 100644 index 00000000000..242b5701f8b --- /dev/null +++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js @@ -0,0 +1,141 @@ +import { shallowMount } from '@vue/test-utils'; +import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { APPLICATION_STATUS } from '~/clusters/constants'; + +const { UPDATING } = APPLICATION_STATUS; + +describe('KnativeDomainEditor', () => { + let wrapper; + let knative; + + const createComponent = (props = {}) => { + wrapper = shallowMount(KnativeDomainEditor, { + propsData: { ...props }, + }); + }; + + beforeEach(() => { + knative = { + title: 'Knative', + hostname: 'example.com', + installed: true, + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('knative has an assigned IP address', () => { + beforeEach(() => { + knative.externalIp = '1.1.1.1'; + createComponent({ knative }); + }); + + it('renders ip address with a clipboard button', () => { + expect(wrapper.find('.js-knative-endpoint').exists()).toBe(true); + expect(wrapper.find('.js-knative-endpoint').element.value).toEqual(knative.externalIp); + }); + + it('displays ip address clipboard button', () => { + expect(wrapper.find('.js-knative-endpoint-clipboard-btn').attributes('text')).toEqual( + knative.externalIp, + ); + }); + + it('renders domain & allows editing', () => { + const domainNameInput = wrapper.find('.js-knative-domainname'); + + expect(domainNameInput.element.value).toEqual(knative.hostname); + expect(domainNameInput.attributes('readonly')).toBeFalsy(); + }); + + it('renders an update/save Knative domain button', () => { + expect(wrapper.find('.js-knative-save-domain-button').exists()).toBe(true); + }); + }); + + describe('knative without ip address', () => { + beforeEach(() => { + knative.externalIp = null; + createComponent({ knative }); + }); + + it('renders an input text with a loading icon', () => { + expect(wrapper.find('.js-knative-ip-loading-icon').exists()).toBe(true); + }); + + it('renders message indicating there is not IP address assigned', () => { + expect(wrapper.find('.js-no-knative-endpoint-message').exists()).toBe(true); + }); + }); + + describe('clicking save changes button', () => { + beforeEach(() => { + createComponent({ knative }); + }); + + it('triggers save event and pass current knative hostname', () => { + wrapper.find(LoadingButton).vm.$emit('click'); + expect(wrapper.emitted('save')[0]).toEqual([knative.hostname]); + }); + }); + + describe('when knative domain name was saved successfully', () => { + beforeEach(() => { + createComponent({ knative }); + }); + + it('displays toast indicating a successful update', () => { + wrapper.vm.$toast = { show: jest.fn() }; + wrapper.setProps({ knative: Object.assign({ updateSuccessful: true }, knative) }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( + 'Knative domain name was updated successfully.', + ); + }); + }); + }); + + describe('when knative domain name input changes', () => { + it('emits "set" event with updated domain name', () => { + const newHostname = 'newhostname.com'; + + wrapper.setData({ knativeHostname: newHostname }); + + expect(wrapper.emitted('set')[0]).toEqual([newHostname]); + }); + }); + + describe('when updating knative domain name failed', () => { + beforeEach(() => { + createComponent({ knative }); + }); + + it('displays an error banner indicating the operation failure', () => { + wrapper.setProps({ knative: { updateFailed: true, ...knative } }); + + expect(wrapper.find('.js-cluster-knative-domain-name-failure-message').exists()).toBe(true); + }); + }); + + describe(`when knative status is ${UPDATING}`, () => { + beforeEach(() => { + createComponent({ knative: { status: UPDATING, ...knative } }); + }); + + it('renders loading spinner in save button', () => { + expect(wrapper.find(LoadingButton).props('loading')).toBe(true); + }); + + it('renders disabled save button', () => { + expect(wrapper.find(LoadingButton).props('disabled')).toBe(true); + }); + + it('renders save button with "Saving" label', () => { + expect(wrapper.find(LoadingButton).props('label')).toBe('Saving'); + }); + }); +}); diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index aa926bb36d7..0d129349799 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -133,6 +133,8 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + updateSuccessful: false, + updateFailed: false, }, cert_manager: { title: 'Cert-Manager', diff --git a/spec/frontend/helpers/jquery.js b/spec/frontend/helpers/jquery.js new file mode 100644 index 00000000000..6421a592c0c --- /dev/null +++ b/spec/frontend/helpers/jquery.js @@ -0,0 +1,6 @@ +import $ from 'jquery'; + +global.$ = $; +global.jQuery = $; + +export default $; diff --git a/spec/frontend/helpers/local_storage_helper.js b/spec/frontend/helpers/local_storage_helper.js new file mode 100644 index 00000000000..48e66b11767 --- /dev/null +++ b/spec/frontend/helpers/local_storage_helper.js @@ -0,0 +1,41 @@ +/** + * 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; + + 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 = () => ({ + clear: jest.fn(), + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +}); + +/** + * Before each test, overwrite `window.localStorage` with a spy implementation. + */ +export const useLocalStorageSpy = () => useLocalStorage(createLocalStorageSpy); diff --git a/spec/frontend/helpers/vue_test_utils_helper.js b/spec/frontend/helpers/vue_test_utils_helper.js index 19e27388eeb..121e99c9783 100644 --- a/spec/frontend/helpers/vue_test_utils_helper.js +++ b/spec/frontend/helpers/vue_test_utils_helper.js @@ -16,4 +16,6 @@ const vNodeContainsText = (vnode, text) => * @param {String} text */ export const shallowWrapperContainsSlotText = (shallowWrapper, slotName, text) => - !!shallowWrapper.vm.$slots[slotName].filter(vnode => vNodeContainsText(vnode, text)).length; + Boolean( + shallowWrapper.vm.$slots[slotName].filter(vnode => vNodeContainsText(vnode, text)).length, + ); diff --git a/spec/frontend/ide/stores/mutations/branch_spec.js b/spec/frontend/ide/stores/mutations/branch_spec.js index 29eb859ddaf..0900b25d5d3 100644 --- a/spec/frontend/ide/stores/mutations/branch_spec.js +++ b/spec/frontend/ide/stores/mutations/branch_spec.js @@ -37,4 +37,39 @@ describe('Multi-file store branch mutations', () => { expect(localState.projects.Example.branches.master.commit.title).toBe('Example commit'); }); }); + + describe('SET_BRANCH_WORKING_REFERENCE', () => { + beforeEach(() => { + localState.projects = { + Foo: { + branches: { + bar: {}, + }, + }, + }; + }); + + it('sets workingReference for existing branch', () => { + mutations.SET_BRANCH_WORKING_REFERENCE(localState, { + projectId: 'Foo', + branchId: 'bar', + reference: 'foo-bar-ref', + }); + + expect(localState.projects.Foo.branches.bar.workingReference).toBe('foo-bar-ref'); + }); + + it('does not fail on non-existent just yet branch', () => { + expect(localState.projects.Foo.branches.unknown).toBeUndefined(); + + mutations.SET_BRANCH_WORKING_REFERENCE(localState, { + projectId: 'Foo', + branchId: 'unknown', + reference: 'fun-fun-ref', + }); + + expect(localState.projects.Foo.branches.unknown).not.toBeUndefined(); + expect(localState.projects.Foo.branches.unknown.workingReference).toBe('fun-fun-ref'); + }); + }); }); diff --git a/spec/frontend/ide/stores/mutations/project_spec.js b/spec/frontend/ide/stores/mutations/project_spec.js new file mode 100644 index 00000000000..b3ce39c33d2 --- /dev/null +++ b/spec/frontend/ide/stores/mutations/project_spec.js @@ -0,0 +1,23 @@ +import mutations from '~/ide/stores/mutations/project'; +import state from '~/ide/stores/state'; + +describe('Multi-file store branch mutations', () => { + let localState; + + beforeEach(() => { + localState = state(); + localState.projects = { abcproject: { empty_repo: true } }; + }); + + describe('TOGGLE_EMPTY_STATE', () => { + it('sets empty_repo for project to passed value', () => { + mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: 'abcproject', value: false }); + + expect(localState.projects.abcproject.empty_repo).toBe(false); + + mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: 'abcproject', value: true }); + + expect(localState.projects.abcproject.empty_repo).toBe(true); + }); + }); +}); diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js index d7908efcf13..343301b8716 100644 --- a/spec/frontend/jobs/store/mutations_spec.js +++ b/spec/frontend/jobs/store/mutations_spec.js @@ -150,44 +150,8 @@ describe('Jobs Store Mutations', () => { }); }); - describe('REQUEST_STAGES', () => { - it('sets isLoadingStages to true', () => { - mutations[types.REQUEST_STAGES](stateCopy); - - expect(stateCopy.isLoadingStages).toEqual(true); - }); - }); - - describe('RECEIVE_STAGES_SUCCESS', () => { - beforeEach(() => { - mutations[types.RECEIVE_STAGES_SUCCESS](stateCopy, [{ name: 'build' }]); - }); - - it('sets isLoadingStages to false', () => { - expect(stateCopy.isLoadingStages).toEqual(false); - }); - - it('sets stages', () => { - expect(stateCopy.stages).toEqual([{ name: 'build' }]); - }); - }); - - describe('RECEIVE_STAGES_ERROR', () => { - beforeEach(() => { - mutations[types.RECEIVE_STAGES_ERROR](stateCopy); - }); - - it('sets isLoadingStages to false', () => { - expect(stateCopy.isLoadingStages).toEqual(false); - }); - - it('resets stages', () => { - expect(stateCopy.stages).toEqual([]); - }); - }); - describe('REQUEST_JOBS_FOR_STAGE', () => { - it('sets isLoadingStages to true', () => { + it('sets isLoadingJobs to true', () => { mutations[types.REQUEST_JOBS_FOR_STAGE](stateCopy, { name: 'deploy' }); expect(stateCopy.isLoadingJobs).toEqual(true); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js new file mode 100644 index 00000000000..9f49e68cfe8 --- /dev/null +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -0,0 +1,436 @@ +import * as datetimeUtility from '~/lib/utils/datetime_utility'; + +describe('Date time utils', () => { + describe('timeFor', () => { + it('returns `past due` when in past', () => { + const date = new Date(); + date.setFullYear(date.getFullYear() - 1); + + expect(datetimeUtility.timeFor(date)).toBe('Past due'); + }); + + it('returns remaining time when in the future', () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + + // Add a day to prevent a transient error. If date is even 1 second + // short of a full year, timeFor will return '11 months remaining' + date.setDate(date.getDate() + 1); + + expect(datetimeUtility.timeFor(date)).toBe('1 year remaining'); + }); + }); + + describe('get day name', () => { + it('should return Sunday', () => { + const day = datetimeUtility.getDayName(new Date('07/17/2016')); + + expect(day).toBe('Sunday'); + }); + + it('should return Monday', () => { + const day = datetimeUtility.getDayName(new Date('07/18/2016')); + + expect(day).toBe('Monday'); + }); + + it('should return Tuesday', () => { + const day = datetimeUtility.getDayName(new Date('07/19/2016')); + + expect(day).toBe('Tuesday'); + }); + + it('should return Wednesday', () => { + const day = datetimeUtility.getDayName(new Date('07/20/2016')); + + expect(day).toBe('Wednesday'); + }); + + it('should return Thursday', () => { + const day = datetimeUtility.getDayName(new Date('07/21/2016')); + + expect(day).toBe('Thursday'); + }); + + it('should return Friday', () => { + const day = datetimeUtility.getDayName(new Date('07/22/2016')); + + expect(day).toBe('Friday'); + }); + + it('should return Saturday', () => { + const day = datetimeUtility.getDayName(new Date('07/23/2016')); + + expect(day).toBe('Saturday'); + }); + }); + + describe('formatDate', () => { + it('should format date properly', () => { + const formattedDate = datetimeUtility.formatDate(new Date('07/23/2016')); + + expect(formattedDate).toBe('Jul 23, 2016 12:00am GMT+0000'); + }); + + it('should format ISO date properly', () => { + const formattedDate = datetimeUtility.formatDate('2016-07-23T00:00:00.559Z'); + + expect(formattedDate).toBe('Jul 23, 2016 12:00am GMT+0000'); + }); + + it('should throw an error if date is invalid', () => { + expect(() => { + datetimeUtility.formatDate('2016-07-23 00:00:00 UTC'); + }).toThrow(new Error('Invalid date')); + }); + }); + + describe('get day difference', () => { + it('should return 7', () => { + const firstDay = new Date('07/01/2016'); + const secondDay = new Date('07/08/2016'); + const difference = datetimeUtility.getDayDifference(firstDay, secondDay); + + expect(difference).toBe(7); + }); + + it('should return 31', () => { + const firstDay = new Date('07/01/2016'); + const secondDay = new Date('08/01/2016'); + const difference = datetimeUtility.getDayDifference(firstDay, secondDay); + + expect(difference).toBe(31); + }); + + it('should return 365', () => { + const firstDay = new Date('07/02/2015'); + const secondDay = new Date('07/01/2016'); + const difference = datetimeUtility.getDayDifference(firstDay, secondDay); + + expect(difference).toBe(365); + }); + }); +}); + +describe('timeIntervalInWords', () => { + it('should return string with number of minutes and seconds', () => { + expect(datetimeUtility.timeIntervalInWords(9.54)).toEqual('9 seconds'); + expect(datetimeUtility.timeIntervalInWords(1)).toEqual('1 second'); + expect(datetimeUtility.timeIntervalInWords(200)).toEqual('3 minutes 20 seconds'); + expect(datetimeUtility.timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds'); + }); +}); + +describe('dateInWords', () => { + const date = new Date('07/01/2016'); + + it('should return date in words', () => { + expect(datetimeUtility.dateInWords(date)).toEqual('July 1, 2016'); + }); + + it('should return abbreviated month name', () => { + expect(datetimeUtility.dateInWords(date, true)).toEqual('Jul 1, 2016'); + }); + + it('should return date in words without year', () => { + expect(datetimeUtility.dateInWords(date, true, true)).toEqual('Jul 1'); + }); +}); + +describe('monthInWords', () => { + const date = new Date('2017-01-20'); + + it('returns month name from provided date', () => { + expect(datetimeUtility.monthInWords(date)).toBe('January'); + }); + + it('returns abbreviated month name from provided date', () => { + expect(datetimeUtility.monthInWords(date, true)).toBe('Jan'); + }); +}); + +describe('totalDaysInMonth', () => { + it('returns number of days in a month for given date', () => { + // 1st Feb, 2016 (leap year) + expect(datetimeUtility.totalDaysInMonth(new Date(2016, 1, 1))).toBe(29); + + // 1st Feb, 2017 + expect(datetimeUtility.totalDaysInMonth(new Date(2017, 1, 1))).toBe(28); + + // 1st Jan, 2017 + expect(datetimeUtility.totalDaysInMonth(new Date(2017, 0, 1))).toBe(31); + }); +}); + +describe('getSundays', () => { + it('returns array of dates representing all Sundays of the month', () => { + // December, 2017 (it has 5 Sundays) + const dateOfSundays = [3, 10, 17, 24, 31]; + const sundays = datetimeUtility.getSundays(new Date(2017, 11, 1)); + + expect(sundays.length).toBe(5); + sundays.forEach((sunday, index) => { + expect(sunday.getDate()).toBe(dateOfSundays[index]); + }); + }); +}); + +describe('getTimeframeWindowFrom', () => { + it('returns array of date objects upto provided length (positive number) into the future starting from provided startDate', () => { + const startDate = new Date(2018, 0, 1); + const mockTimeframe = [ + new Date(2018, 0, 1), + new Date(2018, 1, 1), + new Date(2018, 2, 1), + new Date(2018, 3, 1), + new Date(2018, 4, 31), + ]; + const timeframe = datetimeUtility.getTimeframeWindowFrom(startDate, 5); + + expect(timeframe.length).toBe(5); + timeframe.forEach((timeframeItem, index) => { + expect(timeframeItem.getFullYear()).toBe(mockTimeframe[index].getFullYear()); + expect(timeframeItem.getMonth()).toBe(mockTimeframe[index].getMonth()); + expect(timeframeItem.getDate()).toBe(mockTimeframe[index].getDate()); + }); + }); + + it('returns array of date objects upto provided length (negative number) into the past starting from provided startDate', () => { + const startDate = new Date(2018, 0, 1); + const mockTimeframe = [ + new Date(2018, 0, 1), + new Date(2017, 11, 1), + new Date(2017, 10, 1), + new Date(2017, 9, 1), + new Date(2017, 8, 1), + ]; + const timeframe = datetimeUtility.getTimeframeWindowFrom(startDate, -5); + + expect(timeframe.length).toBe(5); + timeframe.forEach((timeframeItem, index) => { + expect(timeframeItem.getFullYear()).toBe(mockTimeframe[index].getFullYear()); + expect(timeframeItem.getMonth()).toBe(mockTimeframe[index].getMonth()); + expect(timeframeItem.getDate()).toBe(mockTimeframe[index].getDate()); + }); + }); +}); + +describe('formatTime', () => { + const expectedTimestamps = [ + [0, '00:00:00'], + [1000, '00:00:01'], + [42000, '00:00:42'], + [121000, '00:02:01'], + [10921000, '03:02:01'], + [108000000, '30:00:00'], + ]; + + expectedTimestamps.forEach(([milliseconds, expectedTimestamp]) => { + it(`formats ${milliseconds}ms as ${expectedTimestamp}`, () => { + expect(datetimeUtility.formatTime(milliseconds)).toBe(expectedTimestamp); + }); + }); +}); + +describe('datefix', () => { + describe('pad', () => { + it('should add a 0 when length is smaller than 2', () => { + expect(datetimeUtility.pad(2)).toEqual('02'); + }); + + it('should not add a zero when length matches the default', () => { + expect(datetimeUtility.pad(12)).toEqual('12'); + }); + + it('should add a 0 when length is smaller than the provided', () => { + expect(datetimeUtility.pad(12, 3)).toEqual('012'); + }); + }); + + describe('parsePikadayDate', () => { + // removed because of https://gitlab.com/gitlab-org/gitlab-ce/issues/39834 + }); + + describe('pikadayToString', () => { + it('should format a UTC date into yyyy-mm-dd format', () => { + expect(datetimeUtility.pikadayToString(new Date('2020-01-29:00:00'))).toEqual('2020-01-29'); + }); + }); +}); + +describe('prettyTime methods', () => { + const assertTimeUnits = (obj, minutes, hours, days, weeks) => { + expect(obj.minutes).toBe(minutes); + expect(obj.hours).toBe(hours); + expect(obj.days).toBe(days); + expect(obj.weeks).toBe(weeks); + }; + + describe('parseSeconds', () => { + it('should correctly parse a negative value', () => { + const zeroSeconds = datetimeUtility.parseSeconds(-1000); + + assertTimeUnits(zeroSeconds, 16, 0, 0, 0); + }); + + it('should correctly parse a zero value', () => { + const zeroSeconds = datetimeUtility.parseSeconds(0); + + assertTimeUnits(zeroSeconds, 0, 0, 0, 0); + }); + + it('should correctly parse a small non-zero second values', () => { + const subOneMinute = datetimeUtility.parseSeconds(10); + const aboveOneMinute = datetimeUtility.parseSeconds(100); + const manyMinutes = datetimeUtility.parseSeconds(1000); + + assertTimeUnits(subOneMinute, 0, 0, 0, 0); + assertTimeUnits(aboveOneMinute, 1, 0, 0, 0); + assertTimeUnits(manyMinutes, 16, 0, 0, 0); + }); + + it('should correctly parse large second values', () => { + const aboveOneHour = datetimeUtility.parseSeconds(4800); + const aboveOneDay = datetimeUtility.parseSeconds(110000); + const aboveOneWeek = datetimeUtility.parseSeconds(25000000); + + assertTimeUnits(aboveOneHour, 20, 1, 0, 0); + assertTimeUnits(aboveOneDay, 33, 6, 3, 0); + assertTimeUnits(aboveOneWeek, 26, 0, 3, 173); + }); + + it('should correctly accept a custom param for hoursPerDay', () => { + const config = { hoursPerDay: 24 }; + + const aboveOneHour = datetimeUtility.parseSeconds(4800, config); + const aboveOneDay = datetimeUtility.parseSeconds(110000, config); + const aboveOneWeek = datetimeUtility.parseSeconds(25000000, config); + + assertTimeUnits(aboveOneHour, 20, 1, 0, 0); + assertTimeUnits(aboveOneDay, 33, 6, 1, 0); + assertTimeUnits(aboveOneWeek, 26, 8, 4, 57); + }); + + it('should correctly accept a custom param for daysPerWeek', () => { + const config = { daysPerWeek: 7 }; + + const aboveOneHour = datetimeUtility.parseSeconds(4800, config); + const aboveOneDay = datetimeUtility.parseSeconds(110000, config); + const aboveOneWeek = datetimeUtility.parseSeconds(25000000, config); + + assertTimeUnits(aboveOneHour, 20, 1, 0, 0); + assertTimeUnits(aboveOneDay, 33, 6, 3, 0); + assertTimeUnits(aboveOneWeek, 26, 0, 0, 124); + }); + + it('should correctly accept custom params for daysPerWeek and hoursPerDay', () => { + const config = { daysPerWeek: 55, hoursPerDay: 14 }; + + const aboveOneHour = datetimeUtility.parseSeconds(4800, config); + const aboveOneDay = datetimeUtility.parseSeconds(110000, config); + const aboveOneWeek = datetimeUtility.parseSeconds(25000000, config); + + assertTimeUnits(aboveOneHour, 20, 1, 0, 0); + assertTimeUnits(aboveOneDay, 33, 2, 2, 0); + assertTimeUnits(aboveOneWeek, 26, 0, 1, 9); + }); + }); + + describe('stringifyTime', () => { + it('should stringify values with all non-zero units', () => { + const timeObject = { + weeks: 1, + days: 4, + hours: 7, + minutes: 20, + }; + + const timeString = datetimeUtility.stringifyTime(timeObject); + + expect(timeString).toBe('1w 4d 7h 20m'); + }); + + it('should stringify values with some non-zero units', () => { + const timeObject = { + weeks: 0, + days: 4, + hours: 0, + minutes: 20, + }; + + const timeString = datetimeUtility.stringifyTime(timeObject); + + expect(timeString).toBe('4d 20m'); + }); + + it('should stringify values with no non-zero units', () => { + const timeObject = { + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + }; + + const timeString = datetimeUtility.stringifyTime(timeObject); + + expect(timeString).toBe('0m'); + }); + + it('should return non-condensed representation of time object', () => { + const timeObject = { weeks: 1, days: 0, hours: 1, minutes: 0 }; + + expect(datetimeUtility.stringifyTime(timeObject, true)).toEqual('1 week 1 hour'); + }); + }); + + describe('abbreviateTime', () => { + it('should abbreviate stringified times for weeks', () => { + const fullTimeString = '1w 3d 4h 5m'; + + expect(datetimeUtility.abbreviateTime(fullTimeString)).toBe('1w'); + }); + + it('should abbreviate stringified times for non-weeks', () => { + const fullTimeString = '0w 3d 4h 5m'; + + expect(datetimeUtility.abbreviateTime(fullTimeString)).toBe('3d'); + }); + }); +}); + +describe('calculateRemainingMilliseconds', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); + }); + + it('calculates the remaining time for a given end date', () => { + const milliseconds = datetimeUtility.calculateRemainingMilliseconds('2063-04-04T01:44:03Z'); + + expect(milliseconds).toBe(3723000); + }); + + it('returns 0 if the end date has passed', () => { + const milliseconds = datetimeUtility.calculateRemainingMilliseconds('2063-04-03T00:00:00Z'); + + expect(milliseconds).toBe(0); + }); +}); + +describe('newDate', () => { + it('returns new date instance from existing date instance', () => { + const initialDate = new Date(2019, 0, 1); + const copiedDate = datetimeUtility.newDate(initialDate); + + expect(copiedDate.getTime()).toBe(initialDate.getTime()); + + initialDate.setMonth(initialDate.getMonth() + 1); + + expect(copiedDate.getTime()).not.toBe(initialDate.getTime()); + }); + + it('returns date instance when provided date param is not of type date or is undefined', () => { + const initialDate = datetimeUtility.newDate(); + + expect(initialDate instanceof Date).toBe(true); + }); +}); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js new file mode 100644 index 00000000000..eca240c9c18 --- /dev/null +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -0,0 +1,194 @@ +import * as urlUtils from '~/lib/utils/url_utility'; + +describe('URL utility', () => { + describe('webIDEUrl', () => { + afterEach(() => { + gon.relative_url_root = ''; + }); + + describe('without relative_url_root', () => { + it('returns IDE path with route', () => { + expect(urlUtils.webIDEUrl('/gitlab-org/gitlab-ce/merge_requests/1')).toBe( + '/-/ide/project/gitlab-org/gitlab-ce/merge_requests/1', + ); + }); + }); + + describe('with relative_url_root', () => { + beforeEach(() => { + gon.relative_url_root = '/gitlab'; + }); + + it('returns IDE path with route', () => { + expect(urlUtils.webIDEUrl('/gitlab/gitlab-org/gitlab-ce/merge_requests/1')).toBe( + '/gitlab/-/ide/project/gitlab-org/gitlab-ce/merge_requests/1', + ); + }); + }); + }); + + describe('mergeUrlParams', () => { + it('adds w', () => { + expect(urlUtils.mergeUrlParams({ w: 1 }, '#frag')).toBe('?w=1#frag'); + expect(urlUtils.mergeUrlParams({ w: 1 }, '/path#frag')).toBe('/path?w=1#frag'); + expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path')).toBe('https://host/path?w=1'); + expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://host/path#frag')).toBe( + 'https://host/path?w=1#frag', + ); + + expect(urlUtils.mergeUrlParams({ w: 1 }, 'https://h/p?k1=v1#frag')).toBe( + 'https://h/p?k1=v1&w=1#frag', + ); + }); + + it('updates w', () => { + expect(urlUtils.mergeUrlParams({ w: 1 }, '?k1=v1&w=0#frag')).toBe('?k1=v1&w=1#frag'); + }); + + it('adds multiple params', () => { + expect(urlUtils.mergeUrlParams({ a: 1, b: 2, c: 3 }, '#frag')).toBe('?a=1&b=2&c=3#frag'); + }); + + it('adds and updates encoded params', () => { + expect(urlUtils.mergeUrlParams({ a: '&', q: '?' }, '?a=%23#frag')).toBe('?a=%26&q=%3F#frag'); + }); + }); + + describe('removeParams', () => { + describe('when url is passed', () => { + it('removes query param with encoded ampersand', () => { + const url = urlUtils.removeParams(['filter'], '/mail?filter=n%3Djoe%26l%3Dhome'); + + expect(url).toBe('/mail'); + }); + + it('should remove param when url has no other params', () => { + const url = urlUtils.removeParams(['size'], '/feature/home?size=5'); + + expect(url).toBe('/feature/home'); + }); + + it('should remove param when url has other params', () => { + const url = urlUtils.removeParams(['size'], '/feature/home?q=1&size=5&f=html'); + + expect(url).toBe('/feature/home?q=1&f=html'); + }); + + it('should remove param and preserve fragment', () => { + const url = urlUtils.removeParams(['size'], '/feature/home?size=5#H2'); + + expect(url).toBe('/feature/home#H2'); + }); + + it('should remove multiple params', () => { + const url = urlUtils.removeParams(['z', 'a'], '/home?z=11111&l=en_US&a=true#H2'); + + expect(url).toBe('/home?l=en_US#H2'); + }); + }); + }); + + describe('setUrlFragment', () => { + it('should set fragment when url has no fragment', () => { + const url = urlUtils.setUrlFragment('/home/feature', 'usage'); + + expect(url).toBe('/home/feature#usage'); + }); + + it('should set fragment when url has existing fragment', () => { + const url = urlUtils.setUrlFragment('/home/feature#overview', 'usage'); + + expect(url).toBe('/home/feature#usage'); + }); + + it('should set fragment when given fragment includes #', () => { + const url = urlUtils.setUrlFragment('/home/feature#overview', '#install'); + + expect(url).toBe('/home/feature#install'); + }); + }); + + describe('getBaseURL', () => { + beforeEach(() => { + global.window = Object.create(window); + Object.defineProperty(window, 'location', { + value: { + host: 'gitlab.com', + protocol: 'https:', + }, + }); + }); + + it('returns correct base URL', () => { + expect(urlUtils.getBaseURL()).toBe('https://gitlab.com'); + }); + }); + + describe('isAbsoluteOrRootRelative', () => { + const validUrls = ['https://gitlab.com/', 'http://gitlab.com/', '/users/sign_in']; + + const invalidUrls = [' https://gitlab.com/', './file/path', 'notanurl', '<a></a>']; + + it.each(validUrls)(`returns true for %s`, url => { + expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(true); + }); + + it.each(invalidUrls)(`returns false for %s`, url => { + expect(urlUtils.isAbsoluteOrRootRelative(url)).toBe(false); + }); + }); + + describe('isSafeUrl', () => { + const absoluteUrls = [ + 'http://example.org', + 'http://example.org:8080', + 'https://example.org', + 'https://example.org:8080', + 'https://192.168.1.1', + ]; + + const rootRelativeUrls = ['/relative/link']; + + const relativeUrls = ['./relative/link', '../relative/link']; + + const urlsWithoutHost = ['http://', 'https://', 'https:https:https:']; + + /* eslint-disable no-script-url */ + const nonHttpUrls = [ + 'javascript:', + 'javascript:alert("XSS")', + 'jav\tascript:alert("XSS");', + '  javascript:alert("XSS");', + 'ftp://192.168.1.1', + 'file:///', + 'file:///etc/hosts', + ]; + /* eslint-enable no-script-url */ + + // javascript:alert('XSS') + const encodedJavaScriptUrls = [ + 'javascript:alert('XSS')', + 'javascript:alert('XSS')', + 'javascript:alert('XSS')', + '\\u006A\\u0061\\u0076\\u0061\\u0073\\u0063\\u0072\\u0069\\u0070\\u0074\\u003A\\u0061\\u006C\\u0065\\u0072\\u0074\\u0028\\u0027\\u0058\\u0053\\u0053\\u0027\\u0029', + ]; + + const safeUrls = [...absoluteUrls, ...rootRelativeUrls]; + const unsafeUrls = [ + ...relativeUrls, + ...urlsWithoutHost, + ...nonHttpUrls, + ...encodedJavaScriptUrls, + ]; + + describe('with URL constructor support', () => { + it.each(safeUrls)('returns true for %s', url => { + expect(urlUtils.isSafeURL(url)).toBe(true); + }); + + it.each(unsafeUrls)('returns false for %s', url => { + expect(urlUtils.isSafeURL(url)).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap index 5f9f13d591d..a2a7d0ee91e 100644 --- a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap +++ b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap @@ -3,6 +3,7 @@ exports[`MR Popover loaded state matches the snapshot 1`] = ` <glpopover-stub boundary="viewport" + cssclasses="" placement="top" show="" target="" @@ -61,6 +62,7 @@ exports[`MR Popover loaded state matches the snapshot 1`] = ` exports[`MR Popover shows skeleton-loader while apollo is loading 1`] = ` <glpopover-stub boundary="viewport" + cssclasses="" placement="top" show="" target="" diff --git a/spec/frontend/notes/components/note_app_spec.js b/spec/frontend/notes/components/note_app_spec.js new file mode 100644 index 00000000000..ff833d2c899 --- /dev/null +++ b/spec/frontend/notes/components/note_app_spec.js @@ -0,0 +1,322 @@ +import $ from 'helpers/jquery'; +import Vue from 'vue'; +import { mount, createLocalVue } from '@vue/test-utils'; +import NotesApp from '~/notes/components/notes_app.vue'; +import service from '~/notes/services/notes_service'; +import createStore from '~/notes/stores'; +import '~/behaviors/markdown/render_gfm'; +import { setTestTimeout } from 'helpers/timeout'; +// TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-ce/issues/62491) +import * as mockData from '../../../javascripts/notes/mock_data'; + +const originalInterceptors = [...Vue.http.interceptors]; + +const emptyResponseInterceptor = (request, next) => { + next( + request.respondWith(JSON.stringify([]), { + status: 200, + }), + ); +}; + +setTestTimeout(1000); + +describe('note_app', () => { + let mountComponent; + let wrapper; + let store; + + /** + * waits for fetchNotes() to complete + */ + const waitForDiscussionsRequest = () => + new Promise(resolve => { + const { vm } = wrapper.find(NotesApp); + const unwatch = vm.$watch('isFetching', isFetching => { + if (isFetching) { + return; + } + + unwatch(); + resolve(); + }); + }); + + beforeEach(() => { + $('body').attr('data-page', 'projects:merge_requests:show'); + + store = createStore(); + mountComponent = data => { + const propsData = data || { + noteableData: mockData.noteableDataMock, + notesData: mockData.notesDataMock, + userData: mockData.userDataMock, + }; + const localVue = createLocalVue(); + + return mount( + { + components: { + NotesApp, + }, + template: '<div class="js-vue-notes-event"><notes-app v-bind="$attrs" /></div>', + }, + { + attachToDocument: true, + propsData, + store, + localVue, + sync: false, + }, + ); + }; + }); + + afterEach(() => { + wrapper.destroy(); + Vue.http.interceptors = [...originalInterceptors]; + }); + + describe('set data', () => { + beforeEach(() => { + Vue.http.interceptors.push(emptyResponseInterceptor); + wrapper = mountComponent(); + return waitForDiscussionsRequest(); + }); + + it('should set notes data', () => { + expect(store.state.notesData).toEqual(mockData.notesDataMock); + }); + + it('should set issue data', () => { + expect(store.state.noteableData).toEqual(mockData.noteableDataMock); + }); + + it('should set user data', () => { + expect(store.state.userData).toEqual(mockData.userDataMock); + }); + + it('should fetch discussions', () => { + expect(store.state.discussions).toEqual([]); + }); + }); + + describe('render', () => { + beforeEach(() => { + setFixtures('<div class="js-discussions-count"></div>'); + + Vue.http.interceptors.push(mockData.individualNoteInterceptor); + wrapper = mountComponent(); + return waitForDiscussionsRequest(); + }); + + it('should render list of notes', () => { + const note = + mockData.INDIVIDUAL_NOTE_RESPONSE_MAP.GET[ + '/gitlab-org/gitlab-ce/issues/26/discussions.json' + ][0].notes[0]; + + expect( + wrapper + .find('.main-notes-list .note-header-author-name') + .text() + .trim(), + ).toEqual(note.author.name); + + expect(wrapper.find('.main-notes-list .note-text').html()).toContain(note.note_html); + }); + + it('should render form', () => { + expect(wrapper.find('.js-main-target-form').name()).toEqual('form'); + expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual( + 'Write a comment or drag your files here…', + ); + }); + + it('should not render form when commenting is disabled', () => { + wrapper.destroy(); + + store.state.commentsDisabled = true; + wrapper = mountComponent(); + return waitForDiscussionsRequest().then(() => { + expect(wrapper.find('.js-main-target-form').exists()).toBe(false); + }); + }); + + it('should render discussion filter note `commentsDisabled` is true', () => { + wrapper.destroy(); + + store.state.commentsDisabled = true; + wrapper = mountComponent(); + return waitForDiscussionsRequest().then(() => { + expect(wrapper.find('.js-discussion-filter-note').exists()).toBe(true); + }); + }); + + it('should render form comment button as disabled', () => { + expect(wrapper.find('.js-note-new-discussion').attributes('disabled')).toEqual('disabled'); + }); + + it('updates discussions badge', () => { + expect(document.querySelector('.js-discussions-count').textContent).toEqual('2'); + }); + }); + + describe('while fetching data', () => { + beforeEach(() => { + Vue.http.interceptors.push(emptyResponseInterceptor); + wrapper = mountComponent(); + }); + + afterEach(() => waitForDiscussionsRequest()); + + it('renders skeleton notes', () => { + expect(wrapper.find('.animation-container').exists()).toBe(true); + }); + + it('should render form', () => { + expect(wrapper.find('.js-main-target-form').name()).toEqual('form'); + expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual( + 'Write a comment or drag your files here…', + ); + }); + }); + + describe('update note', () => { + describe('individual note', () => { + beforeEach(() => { + Vue.http.interceptors.push(mockData.individualNoteInterceptor); + jest.spyOn(service, 'updateNote'); + wrapper = mountComponent(); + return waitForDiscussionsRequest().then(() => { + wrapper.find('.js-note-edit').trigger('click'); + }); + }); + + it('renders edit form', () => { + expect(wrapper.find('.js-vue-issue-note-form').exists()).toBe(true); + }); + + it('calls the service to update the note', () => { + wrapper.find('.js-vue-issue-note-form').value = 'this is a note'; + wrapper.find('.js-vue-issue-save').trigger('click'); + + expect(service.updateNote).toHaveBeenCalled(); + }); + }); + + describe('discussion note', () => { + beforeEach(() => { + Vue.http.interceptors.push(mockData.discussionNoteInterceptor); + jest.spyOn(service, 'updateNote'); + wrapper = mountComponent(); + return waitForDiscussionsRequest().then(() => { + wrapper.find('.js-note-edit').trigger('click'); + }); + }); + + it('renders edit form', () => { + expect(wrapper.find('.js-vue-issue-note-form').exists()).toBe(true); + }); + + it('updates the note and resets the edit form', () => { + wrapper.find('.js-vue-issue-note-form').value = 'this is a note'; + wrapper.find('.js-vue-issue-save').trigger('click'); + + expect(service.updateNote).toHaveBeenCalled(); + }); + }); + }); + + describe('new note form', () => { + beforeEach(() => { + Vue.http.interceptors.push(mockData.individualNoteInterceptor); + wrapper = mountComponent(); + return waitForDiscussionsRequest(); + }); + + it('should render markdown docs url', () => { + const { markdownDocsPath } = mockData.notesDataMock; + + expect( + wrapper + .find(`a[href="${markdownDocsPath}"]`) + .text() + .trim(), + ).toEqual('Markdown'); + }); + + it('should render quick action docs url', () => { + const { quickActionsDocsPath } = mockData.notesDataMock; + + expect( + wrapper + .find(`a[href="${quickActionsDocsPath}"]`) + .text() + .trim(), + ).toEqual('quick actions'); + }); + }); + + describe('edit form', () => { + beforeEach(() => { + Vue.http.interceptors.push(mockData.individualNoteInterceptor); + wrapper = mountComponent(); + return waitForDiscussionsRequest(); + }); + + it('should render markdown docs url', () => { + wrapper.find('.js-note-edit').trigger('click'); + const { markdownDocsPath } = mockData.notesDataMock; + + return Vue.nextTick().then(() => { + expect( + wrapper + .find(`.edit-note a[href="${markdownDocsPath}"]`) + .text() + .trim(), + ).toEqual('Markdown is supported'); + }); + }); + + it('should not render quick actions docs url', () => { + wrapper.find('.js-note-edit').trigger('click'); + const { quickActionsDocsPath } = mockData.notesDataMock; + expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false); + }); + }); + + describe('emoji awards', () => { + beforeEach(() => { + Vue.http.interceptors.push(emptyResponseInterceptor); + wrapper = mountComponent(); + return waitForDiscussionsRequest(); + }); + + it('dispatches toggleAward after toggleAward event', () => { + const toggleAwardEvent = new CustomEvent('toggleAward', { + detail: { + awardName: 'test', + noteId: 1, + }, + }); + const toggleAwardAction = jest.fn().mockName('toggleAward'); + wrapper.vm.$store.hotUpdate({ + actions: { + toggleAward: toggleAwardAction, + stopPolling() {}, + }, + }); + + wrapper.vm.$parent.$el.dispatchEvent(toggleAwardEvent); + + expect(toggleAwardAction).toHaveBeenCalledTimes(1); + const [, payload] = toggleAwardAction.mock.calls[0]; + + expect(payload).toEqual({ + awardName: 'test', + noteId: 1, + }); + }); + }); +}); diff --git a/spec/frontend/operation_settings/components/external_dashboard_spec.js b/spec/frontend/operation_settings/components/external_dashboard_spec.js index de1dd219fe0..a881de8fbfe 100644 --- a/spec/frontend/operation_settings/components/external_dashboard_spec.js +++ b/spec/frontend/operation_settings/components/external_dashboard_spec.js @@ -1,30 +1,64 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; import { GlButton, GlLink, GlFormGroup, GlFormInput } from '@gitlab/ui'; import ExternalDashboard from '~/operation_settings/components/external_dashboard.vue'; +import store from '~/operation_settings/store'; +import axios from '~/lib/utils/axios_utils'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; import { TEST_HOST } from 'helpers/test_constants'; +jest.mock('~/lib/utils/axios_utils'); +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/flash'); + describe('operation settings external dashboard component', () => { let wrapper; - const externalDashboardPath = `http://mock-external-domain.com/external/dashboard/path`; + const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`; + const externalDashboardUrl = `http://mock-external-domain.com/external/dashboard/url`; const externalDashboardHelpPagePath = `${TEST_HOST}/help/page/path`; - - beforeEach(() => { - wrapper = shallowMount(ExternalDashboard, { - propsData: { - externalDashboardPath, - externalDashboardHelpPagePath, + const localVue = createLocalVue(); + const mountComponent = (shallow = true) => { + const config = [ + ExternalDashboard, + { + localVue, + store: store({ + operationsSettingsEndpoint, + externalDashboardUrl, + externalDashboardHelpPagePath, + }), }, - }); + ]; + wrapper = shallow ? shallowMount(...config) : mount(...config); + }; + + afterEach(() => { + if (wrapper.destroy) { + wrapper.destroy(); + } + axios.patch.mockReset(); + refreshCurrentPage.mockReset(); + createFlash.mockReset(); }); it('renders header text', () => { + mountComponent(); expect(wrapper.find('.js-section-header').text()).toBe('External Dashboard'); }); + describe('expand/collapse button', () => { + it('renders as an expand button by default', () => { + const button = wrapper.find(GlButton); + + expect(button.text()).toBe('Expand'); + }); + }); + describe('sub-header', () => { let subHeader; beforeEach(() => { + mountComponent(); subHeader = wrapper.find('.js-section-sub-header'); }); @@ -43,57 +77,89 @@ describe('operation settings external dashboard component', () => { }); describe('form', () => { - let form; + describe('input label', () => { + let formGroup; - beforeEach(() => { - form = wrapper.find('form'); - }); + beforeEach(() => { + mountComponent(); + formGroup = wrapper.find(GlFormGroup); + }); - describe('external dashboard url', () => { - describe('input label', () => { - let formGroup; + it('uses label text', () => { + expect(formGroup.attributes().label).toBe('Full dashboard URL'); + }); - beforeEach(() => { - formGroup = form.find(GlFormGroup); - }); + it('uses description text', () => { + expect(formGroup.attributes().description).toBe( + 'Enter the URL of the dashboard you want to link to', + ); + }); + }); - it('uses label text', () => { - expect(formGroup.attributes().label).toBe('Full dashboard URL'); - }); + describe('input field', () => { + let input; - it('uses description text', () => { - expect(formGroup.attributes().description).toBe( - 'Enter the URL of the dashboard you want to link to', - ); - }); + beforeEach(() => { + mountComponent(); + input = wrapper.find(GlFormInput); }); - describe('input field', () => { - let input; - - beforeEach(() => { - input = form.find(GlFormInput); - }); + it('defaults to externalDashboardUrl', () => { + expect(input.attributes().value).toBe(externalDashboardUrl); + }); - it('defaults to externalDashboardPath prop', () => { - expect(input.attributes().value).toBe(externalDashboardPath); - }); + it('uses a placeholder', () => { + expect(input.attributes().placeholder).toBe('https://my-org.gitlab.io/my-dashboards'); + }); + }); - it('uses a placeholder', () => { - expect(input.attributes().placeholder).toBe('https://my-org.gitlab.io/my-dashboards'); - }); + describe('submit button', () => { + const findSubmitButton = () => wrapper.find('.settings-content form').find(GlButton); + + const endpointRequest = [ + operationsSettingsEndpoint, + { + project: { + metrics_setting_attributes: { + external_dashboard_url: externalDashboardUrl, + }, + }, + }, + ]; + + it('renders button label', () => { + mountComponent(); + const submit = findSubmitButton(); + expect(submit.text()).toBe('Save Changes'); }); - describe('submit button', () => { - let submit; + it('submits form on click', () => { + mountComponent(false); + axios.patch.mockResolvedValue(); + findSubmitButton().trigger('click'); - beforeEach(() => { - submit = form.find(GlButton); - }); + expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); - it('renders button label', () => { - expect(submit.text()).toBe('Save Changes'); - }); + return wrapper.vm.$nextTick().then(() => expect(refreshCurrentPage).toHaveBeenCalled()); + }); + + it('creates flash banner on error', () => { + mountComponent(false); + const message = 'mockErrorMessage'; + axios.patch.mockRejectedValue({ response: { data: { message } } }); + findSubmitButton().trigger('click'); + + expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); + + return wrapper.vm + .$nextTick() + .then(jest.runAllTicks) + .then(() => + expect(createFlash).toHaveBeenCalledWith( + `There was an error saving your changes. ${message}`, + 'alert', + ), + ); }); }); }); diff --git a/spec/frontend/operation_settings/store/mutations_spec.js b/spec/frontend/operation_settings/store/mutations_spec.js new file mode 100644 index 00000000000..1854142c89a --- /dev/null +++ b/spec/frontend/operation_settings/store/mutations_spec.js @@ -0,0 +1,19 @@ +import mutations from '~/operation_settings/store/mutations'; +import createState from '~/operation_settings/store/state'; + +describe('operation settings mutations', () => { + let localState; + + beforeEach(() => { + localState = createState(); + }); + + describe('SET_EXTERNAL_DASHBOARD_URL', () => { + it('sets externalDashboardUrl', () => { + const mockUrl = 'mockUrl'; + mutations.SET_EXTERNAL_DASHBOARD_URL(localState, mockUrl); + + expect(localState.externalDashboardUrl).toBe(mockUrl); + }); + }); +}); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js new file mode 100644 index 00000000000..068fa317a87 --- /dev/null +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -0,0 +1,44 @@ +import { shallowMount, RouterLinkStub } from '@vue/test-utils'; +import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; + +let vm; + +function factory(currentPath) { + vm = shallowMount(Breadcrumbs, { + propsData: { + currentPath, + }, + stubs: { + RouterLink: RouterLinkStub, + }, + }); +} + +describe('Repository breadcrumbs component', () => { + afterEach(() => { + vm.destroy(); + }); + + it.each` + path | linkCount + ${'/'} | ${1} + ${'app'} | ${2} + ${'app/assets'} | ${3} + ${'app/assets/javascripts'} | ${4} + `('renders $linkCount links for path $path', ({ path, linkCount }) => { + factory(path); + + expect(vm.findAll(RouterLinkStub).length).toEqual(linkCount); + }); + + it('renders last link as active', () => { + factory('app/assets'); + + expect( + vm + .findAll(RouterLinkStub) + .at(2) + .attributes('aria-current'), + ).toEqual('page'); + }); +}); diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap new file mode 100644 index 00000000000..1b4564303e4 --- /dev/null +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Repository table row component renders table row 1`] = ` +<tr + class="tree-item file_1" +> + <td + class="tree-item-file-name" + > + <i + aria-label="file" + class="fa fa-fw fa-file-text-o" + role="img" + /> + + <a + class="str-truncated" + > + + test + + </a> + + <!----> + </td> + + <td + class="d-none d-sm-table-cell tree-commit" + /> + + <td + class="tree-time-ago text-right" + /> +</tr> +`; diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 6f52cffe077..827927e6d9a 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -3,18 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui'; import Table from '~/repository/components/table/index.vue'; let vm; +let $apollo; + +function factory(path, data = () => ({})) { + $apollo = { + query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })), + }; -function factory(path, loading = false) { vm = shallowMount(Table, { propsData: { path, }, mocks: { - $apollo: { - queries: { - files: { loading }, - }, - }, + $apollo, }, }); } @@ -39,9 +40,41 @@ describe('Repository table component', () => { ); }); - it('renders loading icon', () => { - factory('/', true); + it('shows loading icon', () => { + factory('/'); + + vm.setData({ isLoadingFiles: true }); + + expect(vm.find(GlLoadingIcon).isVisible()).toBe(true); + }); + + describe('normalizeData', () => { + it('normalizes edge nodes', () => { + const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]); + + expect(output).toEqual(['1', '2']); + }); + }); + + describe('hasNextPage', () => { + it('returns undefined when hasNextPage is false', () => { + const output = vm.vm.hasNextPage({ + trees: { pageInfo: { hasNextPage: false } }, + submodules: { pageInfo: { hasNextPage: false } }, + blobs: { pageInfo: { hasNextPage: false } }, + }); + + expect(output).toBe(undefined); + }); + + it('returns pageInfo object when hasNextPage is true', () => { + const output = vm.vm.hasNextPage({ + trees: { pageInfo: { hasNextPage: false } }, + submodules: { pageInfo: { hasNextPage: false } }, + blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } }, + }); - expect(vm.find(GlLoadingIcon).exists()).toBe(true); + expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' }); + }); }); }); diff --git a/spec/frontend/repository/components/table/parent_row_spec.js b/spec/frontend/repository/components/table/parent_row_spec.js new file mode 100644 index 00000000000..7020055271f --- /dev/null +++ b/spec/frontend/repository/components/table/parent_row_spec.js @@ -0,0 +1,64 @@ +import { shallowMount, RouterLinkStub } from '@vue/test-utils'; +import ParentRow from '~/repository/components/table/parent_row.vue'; + +let vm; +let $router; + +function factory(path) { + $router = { + push: jest.fn(), + }; + + vm = shallowMount(ParentRow, { + propsData: { + commitRef: 'master', + path, + }, + stubs: { + RouterLink: RouterLinkStub, + }, + mocks: { + $router, + }, + }); +} + +describe('Repository parent row component', () => { + afterEach(() => { + vm.destroy(); + }); + + it.each` + path | to + ${'app'} | ${'/tree/master/'} + ${'app/assets'} | ${'/tree/master/app'} + `('renders link in $path to $to', ({ path, to }) => { + factory(path); + + expect(vm.find(RouterLinkStub).props().to).toEqual({ + path: to, + }); + }); + + it('pushes new router when clicking row', () => { + factory('app/assets'); + + vm.find('td').trigger('click'); + + expect($router.push).toHaveBeenCalledWith({ + path: '/tree/master/app', + }); + }); + + // We test that it does not get called when clicking any internal + // links as this was causing multipe routes to get pushed + it('does not trigger router.push when clicking link', () => { + factory('app/assets'); + + vm.find('a').trigger('click'); + + expect($router.push).not.toHaveBeenCalledWith({ + path: '/tree/master/app', + }); + }); +}); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js new file mode 100644 index 00000000000..6b4508c418e --- /dev/null +++ b/spec/frontend/repository/components/table/row_spec.js @@ -0,0 +1,89 @@ +import { shallowMount, RouterLinkStub } from '@vue/test-utils'; +import TableRow from '~/repository/components/table/row.vue'; + +let vm; +let $router; + +function factory(propsData = {}) { + $router = { + push: jest.fn(), + }; + + vm = shallowMount(TableRow, { + propsData, + mocks: { + $router, + }, + stubs: { + RouterLink: RouterLinkStub, + }, + }); + + vm.setData({ ref: 'master' }); +} + +describe('Repository table row component', () => { + afterEach(() => { + vm.destroy(); + }); + + it('renders table row', () => { + factory({ + id: '1', + path: 'test', + type: 'file', + currentPath: '/', + }); + + expect(vm.element).toMatchSnapshot(); + }); + + it.each` + type | component | componentName + ${'tree'} | ${RouterLinkStub} | ${'RouterLink'} + ${'file'} | ${'a'} | ${'hyperlink'} + ${'commit'} | ${'a'} | ${'hyperlink'} + `('renders a $componentName for type $type', ({ type, component }) => { + factory({ + id: '1', + path: 'test', + type, + currentPath: '/', + }); + + expect(vm.find(component).exists()).toBe(true); + }); + + it.each` + type | pushes + ${'tree'} | ${true} + ${'file'} | ${false} + ${'commit'} | ${false} + `('pushes new router if type $type is tree', ({ type, pushes }) => { + factory({ + id: '1', + path: 'test', + type, + currentPath: '/', + }); + + vm.trigger('click'); + + if (pushes) { + expect($router.push).toHaveBeenCalledWith({ path: '/tree/master/test' }); + } else { + expect($router.push).not.toHaveBeenCalled(); + } + }); + + it('renders commit ID for submodule', () => { + factory({ + id: '1', + path: 'test', + type: 'commit', + currentPath: '/', + }); + + expect(vm.find('.commit-sha').text()).toContain('1'); + }); +}); diff --git a/spec/frontend/repository/utils/icon_spec.js b/spec/frontend/repository/utils/icon_spec.js new file mode 100644 index 00000000000..3d84705f7ea --- /dev/null +++ b/spec/frontend/repository/utils/icon_spec.js @@ -0,0 +1,23 @@ +import { getIconName } from '~/repository/utils/icon'; + +describe('getIconName', () => { + // Tests the returning font awesome icon name + // We only test one for each file type to save testing a lot of different + // file types + it.each` + type | path | icon + ${'tree'} | ${''} | ${'folder'} + ${'commit'} | ${''} | ${'archive'} + ${'file'} | ${'test.pdf'} | ${'file-pdf-o'} + ${'file'} | ${'test.jpg'} | ${'file-image-o'} + ${'file'} | ${'test.zip'} | ${'file-archive-o'} + ${'file'} | ${'test.mp3'} | ${'file-audio-o'} + ${'file'} | ${'test.flv'} | ${'file-video-o'} + ${'file'} | ${'test.dotx'} | ${'file-word-o'} + ${'file'} | ${'test.xlsb'} | ${'file-excel-o'} + ${'file'} | ${'test.ppam'} | ${'file-powerpoint-o'} + ${'file'} | ${'test.js'} | ${'file-text-o'} + `('returns $icon for $type with path $path', ({ type, path, icon }) => { + expect(getIconName(type, path)).toEqual(icon); + }); +}); diff --git a/spec/frontend/repository/utils/title_spec.js b/spec/frontend/repository/utils/title_spec.js new file mode 100644 index 00000000000..c4879716fd7 --- /dev/null +++ b/spec/frontend/repository/utils/title_spec.js @@ -0,0 +1,15 @@ +import { setTitle } from '~/repository/utils/title'; + +describe('setTitle', () => { + it.each` + path | title + ${'/'} | ${'Files'} + ${'app'} | ${'app'} + ${'app/assets'} | ${'app/assets'} + ${'app/assets/javascripts'} | ${'app/assets/javascripts'} + `('sets document title as $title for $path', ({ path, title }) => { + setTitle(path, 'master', 'GitLab'); + + expect(document.title).toEqual(`${title} · master · GitLab`); + }); +}); diff --git a/spec/frontend/serverless/components/environment_row_spec.js b/spec/frontend/serverless/components/environment_row_spec.js index 161a637dd75..0ad85e218dc 100644 --- a/spec/frontend/serverless/components/environment_row_spec.js +++ b/spec/frontend/serverless/components/environment_row_spec.js @@ -14,7 +14,7 @@ describe('environment row component', () => { beforeEach(() => { localVue = createLocalVue(); - vm = createComponent(localVue, translate(mockServerlessFunctions)['*'], '*'); + vm = createComponent(localVue, translate(mockServerlessFunctions.functions)['*'], '*'); }); afterEach(() => vm.$destroy()); @@ -48,7 +48,11 @@ describe('environment row component', () => { beforeEach(() => { localVue = createLocalVue(); - vm = createComponent(localVue, translate(mockServerlessFunctionsDiffEnv).test, 'test'); + vm = createComponent( + localVue, + translate(mockServerlessFunctionsDiffEnv.functions).test, + 'test', + ); }); afterEach(() => vm.$destroy()); diff --git a/spec/frontend/serverless/components/functions_spec.js b/spec/frontend/serverless/components/functions_spec.js index 6924fb9e91f..d8a80f8031e 100644 --- a/spec/frontend/serverless/components/functions_spec.js +++ b/spec/frontend/serverless/components/functions_spec.js @@ -34,11 +34,11 @@ describe('functionsComponent', () => { }); it('should render empty state when Knative is not installed', () => { + store.dispatch('receiveFunctionsSuccess', { knative_installed: false }); component = shallowMount(functionsComponent, { localVue, store, propsData: { - installed: false, clustersPath: '', helpPath: '', statusPath: '', @@ -55,7 +55,6 @@ describe('functionsComponent', () => { localVue, store, propsData: { - installed: true, clustersPath: '', helpPath: '', statusPath: '', @@ -67,12 +66,11 @@ describe('functionsComponent', () => { }); it('should render empty state when there is no function data', () => { - store.dispatch('receiveFunctionsNoDataSuccess'); + store.dispatch('receiveFunctionsNoDataSuccess', { knative_installed: true }); component = shallowMount(functionsComponent, { localVue, store, propsData: { - installed: true, clustersPath: '', helpPath: '', statusPath: '', @@ -91,12 +89,31 @@ describe('functionsComponent', () => { ); }); + it('should render functions and a loader when functions are partially fetched', () => { + store.dispatch('receiveFunctionsPartial', { + ...mockServerlessFunctions, + knative_installed: 'checking', + }); + component = shallowMount(functionsComponent, { + localVue, + store, + propsData: { + clustersPath: '', + helpPath: '', + statusPath: '', + }, + sync: false, + }); + + expect(component.find('.js-functions-wrapper').exists()).toBe(true); + expect(component.find('.js-functions-loader').exists()).toBe(true); + }); + it('should render the functions list', () => { component = shallowMount(functionsComponent, { localVue, store, propsData: { - installed: true, clustersPath: 'clustersPath', helpPath: 'helpPath', statusPath, diff --git a/spec/frontend/serverless/mock_data.js b/spec/frontend/serverless/mock_data.js index a2c18616324..ef616ceb37f 100644 --- a/spec/frontend/serverless/mock_data.js +++ b/spec/frontend/serverless/mock_data.js @@ -1,56 +1,62 @@ -export const mockServerlessFunctions = [ - { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'A test service', - image: 'knative-test-container-buildtemplate', - }, - { - name: 'testfunc2', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc2.tm-example.apps.example.com', - description: 'A second test service\nThis one with additional descriptions', - image: 'knative-test-echo-buildtemplate', - }, -]; +export const mockServerlessFunctions = { + knative_installed: true, + functions: [ + { + name: 'testfunc1', + namespace: 'tm-example', + environment_scope: '*', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc1.tm-example.apps.example.com', + description: 'A test service', + image: 'knative-test-container-buildtemplate', + }, + { + name: 'testfunc2', + namespace: 'tm-example', + environment_scope: '*', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc2.tm-example.apps.example.com', + description: 'A second test service\nThis one with additional descriptions', + image: 'knative-test-echo-buildtemplate', + }, + ], +}; -export const mockServerlessFunctionsDiffEnv = [ - { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'A test service', - image: 'knative-test-container-buildtemplate', - }, - { - name: 'testfunc2', - namespace: 'tm-example', - environment_scope: 'test', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc2.tm-example.apps.example.com', - description: 'A second test service\nThis one with additional descriptions', - image: 'knative-test-echo-buildtemplate', - }, -]; +export const mockServerlessFunctionsDiffEnv = { + knative_installed: true, + functions: [ + { + name: 'testfunc1', + namespace: 'tm-example', + environment_scope: '*', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc1.tm-example.apps.example.com', + description: 'A test service', + image: 'knative-test-container-buildtemplate', + }, + { + name: 'testfunc2', + namespace: 'tm-example', + environment_scope: 'test', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc2.tm-example.apps.example.com', + description: 'A second test service\nThis one with additional descriptions', + image: 'knative-test-echo-buildtemplate', + }, + ], +}; export const mockServerlessFunction = { name: 'testfunc1', diff --git a/spec/frontend/serverless/store/getters_spec.js b/spec/frontend/serverless/store/getters_spec.js index fb549c8f153..92853fda37c 100644 --- a/spec/frontend/serverless/store/getters_spec.js +++ b/spec/frontend/serverless/store/getters_spec.js @@ -32,7 +32,7 @@ describe('Serverless Store Getters', () => { describe('getFunctions', () => { it('should translate the raw function array to group the functions per environment scope', () => { - state.functions = mockServerlessFunctions; + state.functions = mockServerlessFunctions.functions; const funcs = getters.getFunctions(state); diff --git a/spec/frontend/serverless/store/mutations_spec.js b/spec/frontend/serverless/store/mutations_spec.js index ca3053e5c38..e2771c7e5fd 100644 --- a/spec/frontend/serverless/store/mutations_spec.js +++ b/spec/frontend/serverless/store/mutations_spec.js @@ -19,13 +19,13 @@ describe('ServerlessMutations', () => { expect(state.isLoading).toEqual(false); expect(state.hasFunctionData).toEqual(true); - expect(state.functions).toEqual(mockServerlessFunctions); + expect(state.functions).toEqual(mockServerlessFunctions.functions); }); it('should ensure loading has stopped and hasFunctionData is false when there are no functions available', () => { const state = {}; - mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state); + mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, { knative_installed: true }); expect(state.isLoading).toEqual(false); expect(state.hasFunctionData).toEqual(false); diff --git a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js index b356ea85cad..0f5d47b3bfe 100644 --- a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js @@ -4,7 +4,7 @@ describe('getStateKey', () => { it('should return proper state name', () => { const context = { mergeStatus: 'checked', - mergeWhenPipelineSucceeds: false, + autoMergeEnabled: false, canMerge: true, onlyAllowMergeIfPipelineSucceeds: false, isPipelineFailed: false, @@ -31,9 +31,9 @@ describe('getStateKey', () => { expect(bound()).toEqual('notAllowedToMerge'); - context.mergeWhenPipelineSucceeds = true; + context.autoMergeEnabled = true; - expect(bound()).toEqual('mergeWhenPipelineSucceeds'); + expect(bound()).toEqual('autoMergeEnabled'); context.isSHAMismatch = true; @@ -80,7 +80,7 @@ describe('getStateKey', () => { it('returns rebased state key', () => { const context = { mergeStatus: 'checked', - mergeWhenPipelineSucceeds: false, + autoMergeEnabled: false, canMerge: true, onlyAllowMergeIfPipelineSucceeds: true, isPipelineFailed: true, |