diff options
Diffstat (limited to 'spec/frontend')
365 files changed, 15990 insertions, 4429 deletions
diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js index 237f8b408f5..94e3f624c25 100644 --- a/spec/frontend/__mocks__/@gitlab/ui.js +++ b/spec/frontend/__mocks__/@gitlab/ui.js @@ -18,7 +18,7 @@ jest.mock('@gitlab/ui/dist/directives/tooltip.js', () => ({ })); jest.mock('@gitlab/ui/dist/components/base/tooltip/tooltip.js', () => ({ - props: ['target', 'id', 'triggers', 'placement', 'container', 'boundary', 'disabled'], + props: ['target', 'id', 'triggers', 'placement', 'container', 'boundary', 'disabled', 'show'], render(h) { return h( 'div', @@ -38,8 +38,16 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({ required: false, default: () => [], }, + ...Object.fromEntries(['target', 'triggers', 'placement'].map(prop => [prop, {}])), }, render(h) { - return h('div', this.$attrs, Object.keys(this.$slots).map(s => this.$slots[s])); + return h( + 'div', + { + class: 'gl-popover', + ...this.$attrs, + }, + Object.keys(this.$slots).map(s => this.$slots[s]), + ); }, })); diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap new file mode 100644 index 00000000000..33c29cea6d8 --- /dev/null +++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`~/access_tokens/components/expires_at_field should render datepicker with input info 1`] = ` +<gl-datepicker-stub + ariallabel="" + autocomplete="" + container="" + displayfield="true" + firstday="0" + mindate="Mon Jul 06 2020 00:00:00 GMT+0000 (Greenwich Mean Time)" + placeholder="YYYY-MM-DD" + theme="" +> + <gl-form-input-stub + autocomplete="off" + class="datepicker gl-datepicker-input" + data-qa-selector="expiry_date_field" + id="personal_access_token_expires_at" + inputmode="none" + name="personal_access_token[expires_at]" + placeholder="YYYY-MM-DD" + /> +</gl-datepicker-stub> +`; diff --git a/spec/frontend/access_tokens/components/expires_at_field_spec.js b/spec/frontend/access_tokens/components/expires_at_field_spec.js new file mode 100644 index 00000000000..cd235d0afa5 --- /dev/null +++ b/spec/frontend/access_tokens/components/expires_at_field_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import { useFakeDate } from 'helpers/fake_date'; +import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; + +describe('~/access_tokens/components/expires_at_field', () => { + useFakeDate(); + + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(ExpiresAtField, { + propsData: { + inputAttrs: { + id: 'personal_access_token_expires_at', + name: 'personal_access_token[expires_at]', + placeholder: 'YYYY-MM-DD', + }, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should render datepicker with input info', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_details_spec.js b/spec/frontend/alert_management/components/alert_details_spec.js index f3ebdfc5cc2..e2d913398f9 100644 --- a/spec/frontend/alert_management/components/alert_details_spec.js +++ b/spec/frontend/alert_management/components/alert_details_spec.js @@ -20,11 +20,7 @@ const environmentName = 'Production'; const environmentPath = '/fake/path'; describe('AlertDetails', () => { - let environmentData = { - name: environmentName, - path: environmentPath, - }; - let glFeatures = { exposeEnvironmentPathInAlertDetails: false }; + let environmentData = { name: environmentName, path: environmentPath }; let mock; let wrapper; const projectPath = 'root/alerts'; @@ -40,7 +36,6 @@ describe('AlertDetails', () => { projectPath, projectIssuesPath, projectId, - glFeatures, }, data() { return { @@ -159,33 +154,21 @@ describe('AlertDetails', () => { }); describe('environment fields', () => { - describe('when exposeEnvironmentPathInAlertDetails is disabled', () => { - beforeEach(mountComponent); + it('should show the environment name with a link to the path', () => { + mountComponent(); + const path = findEnvironmentPath(); - it('should not show the environment', () => { - expect(findEnvironmentName().exists()).toBe(false); - expect(findEnvironmentPath().exists()).toBe(false); - }); + expect(findEnvironmentName().exists()).toBe(false); + expect(path.text()).toBe(environmentName); + expect(path.attributes('href')).toBe(environmentPath); }); - describe('when exposeEnvironmentPathInAlertDetails is enabled', () => { - beforeEach(() => { - glFeatures = { exposeEnvironmentPathInAlertDetails: true }; - mountComponent(); - }); - - it('should show the environment name with link to path', () => { - expect(findEnvironmentName().exists()).toBe(false); - expect(findEnvironmentPath().text()).toBe(environmentName); - expect(findEnvironmentPath().attributes('href')).toBe(environmentPath); - }); + it('should only show the environment name if the path is not provided', () => { + environmentData = { name: environmentName, path: null }; + mountComponent(); - it('should only show the environment name if the path is not provided', () => { - environmentData = { name: environmentName, path: null }; - mountComponent(); - expect(findEnvironmentPath().exists()).toBe(false); - expect(findEnvironmentName().text()).toBe(environmentName); - }); + expect(findEnvironmentPath().exists()).toBe(false); + expect(findEnvironmentName().text()).toBe(environmentName); }); }); @@ -195,6 +178,7 @@ describe('AlertDetails', () => { mountComponent({ data: { alert: { ...mockAlert, issueIid }, sidebarStatus: false }, }); + expect(findViewIncidentBtn().exists()).toBe(true); expect(findViewIncidentBtn().attributes('href')).toBe( joinPaths(projectIssuesPath, issueIid), @@ -220,8 +204,8 @@ describe('AlertDetails', () => { jest .spyOn(wrapper.vm.$apollo, 'mutate') .mockResolvedValue({ data: { createAlertIssue: { issue: { iid: issueIid } } } }); - findCreateIncidentBtn().trigger('click'); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: createIssueMutation, variables: { @@ -251,6 +235,7 @@ describe('AlertDetails', () => { beforeEach(() => { mountComponent({ data: { alert: mockAlert } }); }); + it('should display a table of raw alert details data', () => { expect(findDetailsTable().exists()).toBe(true); }); diff --git a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap deleted file mode 100644 index 5800b160efe..00000000000 --- a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap +++ /dev/null @@ -1,50 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AlertsSettingsForm with default values renders the initial template 1`] = ` -"<div> - <integrations-list-stub integrations=\\"[object Object],[object Object]\\"></integrations-list-stub> - <gl-form-stub> - <h5 class=\\"gl-font-lg gl-my-5\\">Add new integrations</h5> - <!----> - <div data-testid=\\"alert-settings-description\\"> - <p> - <gl-sprintf-stub message=\\"You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.\\"></gl-sprintf-stub> - </p> - <p> - <gl-sprintf-stub message=\\"Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.\\"></gl-sprintf-stub> - </p> - </div> - <gl-form-group-stub label-for=\\"integration-type\\" label=\\"Integration\\"> - <gl-form-select-stub id=\\"integration-type\\" options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"generic\\"></gl-form-select-stub> <span class=\\"gl-text-gray-500\\"><gl-sprintf-stub message=\\"Learn more about our improvements for %{linkStart}integrations%{linkEnd}\\"></gl-sprintf-stub></span> - </gl-form-group-stub> - <gl-form-group-stub label=\\"Active\\" label-for=\\"activated\\"> - <toggle-button-stub id=\\"activated\\"></toggle-button-stub> - </gl-form-group-stub> - <!----> - <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\"> - <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-500\\"> - - </span> - </gl-form-group-stub> - <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\"> - <gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub> - <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub> - <gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\"> - Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in. - </gl-modal-stub> - </gl-form-group-stub> - <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\"> - <gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub> - </gl-form-group-stub> - <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub> - <div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\"> - <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\"> - Save changes - </gl-button-stub> - <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\"> - Cancel - </gl-button-stub> - </div> - </gl-form-stub> -</div>" -`; diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap new file mode 100644 index 00000000000..e2ef7483316 --- /dev/null +++ b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlertsSettingsFormNew with default values renders the initial template 1`] = ` +"<form class=\\"gl-mt-6\\"> + <h5 class=\\"gl-font-lg gl-my-5\\">Add new integrations</h5> + <div id=\\"integration-type\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"integration-type__BV_label_\\" for=\\"integration-type\\" class=\\"d-block col-form-label\\">1. Select integration type</label> + <div class=\\"bv-no-focus-ring\\"><select class=\\"gl-form-select custom-select\\" id=\\"__BVID__8\\"> + <option value=\\"\\">Select integration type</option> + <option value=\\"HTTP\\">HTTP Endpoint</option> + <option value=\\"PROMETHEUS\\">External Prometheus</option> + <option value=\\"OPSGENIE\\">Opsgenie</option> + </select> + <!----> + <!----> + <!----> + <!----> + </div> + </div> + <div class=\\"gl-mt-3 collapse\\" style=\\"display: none;\\" id=\\"__BVID__10\\"> + <div> + <div id=\\"name-integration\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"name-integration__BV_label_\\" for=\\"name-integration\\" class=\\"d-block col-form-label\\">2. Name integration</label> + <div class=\\"bv-no-focus-ring\\"><input type=\\"text\\" placeholder=\\"Enter integration name\\" class=\\"gl-form-input form-control\\" id=\\"__BVID__15\\"> + <!----> + <!----> + <!----> + </div> + </div> + <div id=\\"integration-webhook\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"integration-webhook__BV_label_\\" for=\\"integration-webhook\\" class=\\"d-block col-form-label\\">3. Set up webhook</label> + <div class=\\"bv-no-focus-ring\\"><span>Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html\\" class=\\"gl-link gl-display-inline-block\\">GitLab documentation</a> to learn more about configuring your endpoint.</span> <label class=\\"gl-display-flex gl-flex-direction-column gl-mb-0 gl-w-max-content gl-my-4 gl-font-weight-normal\\"> + <div class=\\"gl-toggle-wrapper\\"><span class=\\"gl-toggle-label\\">Active</span> + <!----> <button aria-label=\\"Active\\" type=\\"button\\" class=\\"gl-toggle\\"><span class=\\"toggle-icon\\"><svg data-testid=\\"close-icon\\" class=\\"gl-icon s16\\"><use href=\\"#close\\"></use></svg></span></button></div> + <!----> + </label> + <!----> + <div class=\\"gl-my-4\\"><span class=\\"gl-font-weight-bold\\"> + Webhook URL + </span> + <div id=\\"url\\" readonly=\\"readonly\\"> + <div role=\\"group\\" class=\\"input-group\\"> + <!----> + <!----> <input id=\\"url\\" type=\\"text\\" readonly=\\"readonly\\" class=\\"gl-form-input form-control\\"> + <div class=\\"input-group-append\\"><button title=\\"Copy\\" data-clipboard-text=\\"\\" aria-label=\\"Copy this value\\" type=\\"button\\" class=\\"btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon\\"> + <!----> <svg data-testid=\\"copy-to-clipboard-icon\\" class=\\"gl-button-icon gl-icon s16\\"> + <use href=\\"#copy-to-clipboard\\"></use> + </svg> + <!----></button></div> + <!----> + </div> + </div> + </div> + <div class=\\"gl-my-4\\"><span class=\\"gl-font-weight-bold\\"> + Authorization key + </span> + <div id=\\"authorization-key\\" readonly=\\"readonly\\" class=\\"gl-mb-3\\"> + <div role=\\"group\\" class=\\"input-group\\"> + <!----> + <!----> <input id=\\"authorization-key\\" type=\\"text\\" readonly=\\"readonly\\" class=\\"gl-form-input form-control\\"> + <div class=\\"input-group-append\\"><button title=\\"Copy\\" data-clipboard-text=\\"\\" aria-label=\\"Copy this value\\" type=\\"button\\" class=\\"btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon\\"> + <!----> <svg data-testid=\\"copy-to-clipboard-icon\\" class=\\"gl-button-icon gl-icon s16\\"> + <use href=\\"#copy-to-clipboard\\"></use> + </svg> + <!----></button></div> + <!----> + </div> + </div> <button type=\\"button\\" disabled=\\"disabled\\" class=\\"btn btn-default btn-md disabled gl-button\\"> + <!----> + <!----> <span class=\\"gl-button-text\\"> + Reset Key + </span></button> + <!----> + </div> + <!----> + <!----> + <!----> + </div> + </div> + <div id=\\"test-integration\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"test-integration__BV_label_\\" for=\\"test-integration\\" class=\\"d-block col-form-label\\">4. Sample alert payload (optional)</label> + <div class=\\"bv-no-focus-ring\\"><span>Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).</span> <textarea id=\\"test-payload\\" disabled=\\"disabled\\" placeholder=\\"{ "events": [{ "application": "Name of application" }] }\\" wrap=\\"soft\\" class=\\"gl-form-input gl-form-textarea gl-my-3 form-control is-valid\\" style=\\"resize: none; overflow-y: scroll;\\"></textarea> + <!----> + <!----> + <!----> + </div> + </div> + <!----> + <!----> + </div> + <div class=\\"gl-display-flex gl-justify-content-start gl-py-3\\"><button data-testid=\\"integration-form-submit\\" type=\\"submit\\" class=\\"btn js-no-auto-disable btn-success btn-md gl-button\\"> + <!----> + <!----> <span class=\\"gl-button-text\\">Save integration + </span></button> <button data-testid=\\"integration-test-and-submit\\" type=\\"button\\" class=\\"btn gl-mx-3 js-no-auto-disable btn-success btn-md gl-button btn-success-secondary\\"> + <!----> + <!----> <span class=\\"gl-button-text\\">Save and test payload</span></button> <button type=\\"reset\\" class=\\"btn js-no-auto-disable btn-default btn-md gl-button\\"> + <!----> + <!----> <span class=\\"gl-button-text\\">Cancel</span></button></div> + </div> +</form>" +`; diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap new file mode 100644 index 00000000000..9306bf24baf --- /dev/null +++ b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlertsSettingsFormOld with default values renders the initial template 1`] = ` +"<gl-form-stub> + <h5 class=\\"gl-font-lg gl-my-5\\"></h5> + <!----> + <div data-testid=\\"alert-settings-description\\"> + <p> + <gl-sprintf-stub message=\\"You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.\\"></gl-sprintf-stub> + </p> + <p> + <gl-sprintf-stub message=\\"Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.\\"></gl-sprintf-stub> + </p> + </div> + <gl-form-group-stub label-for=\\"integration-type\\" label=\\"Integration\\"> + <gl-form-select-stub id=\\"integration-type\\" options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"HTTP\\"></gl-form-select-stub> <span class=\\"gl-text-gray-500\\"><gl-sprintf-stub message=\\"Learn more about our our upcoming %{linkStart}integrations%{linkEnd}\\"></gl-sprintf-stub></span> + </gl-form-group-stub> + <gl-form-group-stub label=\\"Active\\" label-for=\\"active\\"> + <toggle-button-stub id=\\"active\\"></toggle-button-stub> + </gl-form-group-stub> + <!----> + <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\"> + <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-500\\"> + + </span> + </gl-form-group-stub> + <gl-form-group-stub label-for=\\"authorization-key\\"> + <gl-form-input-group-stub value=\\"\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub> + <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub> + <gl-modal-stub modalid=\\"tokenModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\"> + Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in. + </gl-modal-stub> + </gl-form-group-stub> + <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\"> + <gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub> + </gl-form-group-stub> + <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub> + <div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\"> + <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\"> + Save changes + </gl-button-stub> + <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\"> + Cancel + </gl-button-stub> + </div> +</gl-form-stub>" +`; diff --git a/spec/frontend/alerts_settings/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/alert_mapping_builder_spec.js new file mode 100644 index 00000000000..12536c27dfe --- /dev/null +++ b/spec/frontend/alerts_settings/alert_mapping_builder_spec.js @@ -0,0 +1,97 @@ +import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue'; +import gitlabFields from '~/alerts_settings/components/mocks/gitlabFields.json'; +import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; + +describe('AlertMappingBuilder', () => { + let wrapper; + + function mountComponent() { + wrapper = shallowMount(AlertMappingBuilder, { + propsData: { + payloadFields: parsedMapping.samplePayload.payloadAlerFields.nodes, + mapping: parsedMapping.storedMapping.nodes, + }, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + beforeEach(() => { + mountComponent(); + }); + + const findColumnInRow = (row, column) => + wrapper + .findAll('.gl-display-table-row') + .at(row) + .findAll('.gl-display-table-cell ') + .at(column); + + it('renders column captions', () => { + expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle); + expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle); + expect(findColumnInRow(0, 3).text()).toContain(i18n.columns.fallbackKeyTitle); + + const fallbackColumnIcon = findColumnInRow(0, 3).find(GlIcon); + expect(fallbackColumnIcon.exists()).toBe(true); + expect(fallbackColumnIcon.attributes('name')).toBe('question'); + expect(fallbackColumnIcon.attributes('title')).toBe(i18n.fallbackTooltip); + }); + + it('renders disabled form input for each mapped field', () => { + gitlabFields.forEach((field, index) => { + const input = findColumnInRow(index + 1, 0).find(GlFormInput); + expect(input.attributes('value')).toBe(`${field.label} (${field.type.join(' or ')})`); + expect(input.attributes('disabled')).toBe(''); + }); + }); + + it('renders right arrow next to each input', () => { + gitlabFields.forEach((field, index) => { + const arrow = findColumnInRow(index + 1, 1).find('.right-arrow'); + expect(arrow.exists()).toBe(true); + }); + }); + + it('renders mapping dropdown for each field', () => { + gitlabFields.forEach(({ compatibleTypes }, index) => { + const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown); + const searchBox = dropdown.find(GlSearchBoxByType); + const dropdownItems = dropdown.findAll(GlDropdownItem); + const { nodes } = parsedMapping.samplePayload.payloadAlerFields; + const numberOfMappingOptions = nodes.filter(({ type }) => + type.some(t => compatibleTypes.includes(t)), + ); + + expect(dropdown.exists()).toBe(true); + expect(searchBox.exists()).toBe(true); + expect(dropdownItems).toHaveLength(numberOfMappingOptions.length); + }); + }); + + it('renders fallback dropdown only for the fields that have fallback', () => { + gitlabFields.forEach(({ compatibleTypes, numberOfFallbacks }, index) => { + const dropdown = findColumnInRow(index + 1, 3).find(GlDropdown); + expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks)); + + if (numberOfFallbacks) { + const searchBox = dropdown.find(GlSearchBoxByType); + const dropdownItems = dropdown.findAll(GlDropdownItem); + const { nodes } = parsedMapping.samplePayload.payloadAlerFields; + const numberOfMappingOptions = nodes.filter(({ type }) => + type.some(t => compatibleTypes.includes(t)), + ); + + expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks)); + expect(dropdownItems).toHaveLength(numberOfMappingOptions.length); + } + }); + }); +}); diff --git a/spec/frontend/alert_settings/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js index 6fc9901db2a..90bb38f0c2b 100644 --- a/spec/frontend/alert_settings/alerts_integrations_list_spec.js +++ b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js @@ -1,19 +1,22 @@ -import { GlTable, GlIcon } from '@gitlab/ui'; +import { GlTable, GlIcon, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import Tracking from '~/tracking'; import AlertIntegrationsList, { i18n, } from '~/alerts_settings/components/alerts_integrations_list.vue'; -import { trackAlertIntergrationsViewsOptions } from '~/alerts_settings/constants'; +import { trackAlertIntegrationsViewsOptions } from '~/alerts_settings/constants'; const mockIntegrations = [ { - activated: true, + id: '1', + active: true, name: 'Integration 1', type: 'HTTP endpoint', }, { - activated: false, + id: '2', + active: false, name: 'Integration 2', type: 'HTTP endpoint', }, @@ -21,15 +24,23 @@ const mockIntegrations = [ describe('AlertIntegrationsList', () => { let wrapper; + const { trigger: triggerIntersection } = useMockIntersectionObserver(); - function mountComponent(propsData = {}) { + function mountComponent({ data = {}, props = {} } = {}) { wrapper = mount(AlertIntegrationsList, { + data() { + return { ...data }; + }, propsData: { integrations: mockIntegrations, - ...propsData, + ...props, + }, + provide: { + glFeatures: { httpIntegrationsList: true }, }, stubs: { GlIcon: true, + GlButton: true, }, }); } @@ -46,6 +57,7 @@ describe('AlertIntegrationsList', () => { }); const findTableComponent = () => wrapper.find(GlTable); + const findTableComponentRows = () => wrapper.find(GlTable).findAll('table tbody tr'); const finsStatusCell = () => wrapper.findAll('[data-testid="integration-activated-status"]'); it('renders a table', () => { @@ -53,10 +65,23 @@ describe('AlertIntegrationsList', () => { }); it('renders an empty state when no integrations provided', () => { - mountComponent({ integrations: [] }); + mountComponent({ props: { integrations: [] } }); expect(findTableComponent().text()).toContain(i18n.emptyState); }); + it('renders an an edit and delete button for each integration', () => { + expect(findTableComponent().findAll(GlButton).length).toBe(4); + }); + + it('renders an highlighted row when a current integration is selected to edit', () => { + mountComponent({ data: { currentIntegration: { id: '1' } } }); + expect( + findTableComponentRows() + .at(0) + .classes(), + ).toContain('gl-bg-blue-50'); + }); + describe('integration status', () => { it('enabled', () => { const cell = finsStatusCell().at(0); @@ -77,12 +102,23 @@ describe('AlertIntegrationsList', () => { describe('Snowplow tracking', () => { beforeEach(() => { - jest.spyOn(Tracking, 'event'); mountComponent(); + jest.spyOn(Tracking, 'event'); + }); + + it('should NOT track alert list page views when list is collapsed', () => { + triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: false } }); + + expect(Tracking.event).not.toHaveBeenCalled(); }); - it('should track alert list page views', () => { - const { category, action } = trackAlertIntergrationsViewsOptions; + it('should track alert list page views only once when list is expanded', () => { + triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } }); + triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } }); + triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } }); + + const { category, action } = trackAlertIntegrationsViewsOptions; + expect(Tracking.event).toHaveBeenCalledTimes(1); expect(Tracking.event).toHaveBeenCalledWith(category, action); }); }); diff --git a/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js new file mode 100644 index 00000000000..fbd482b1906 --- /dev/null +++ b/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js @@ -0,0 +1,364 @@ +import { mount } from '@vue/test-utils'; +import { + GlForm, + GlFormSelect, + GlCollapse, + GlFormInput, + GlToggle, + GlFormTextarea, +} from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form_new.vue'; +import { defaultAlertSettingsConfig } from './util'; +import { typeSet } from '~/alerts_settings/constants'; + +describe('AlertsSettingsFormNew', () => { + let wrapper; + const mockToastShow = jest.fn(); + + const createComponent = ({ + data = {}, + props = {}, + multipleHttpIntegrationsCustomMapping = false, + } = {}) => { + wrapper = mount(AlertsSettingsForm, { + data() { + return { ...data }; + }, + propsData: { + loading: false, + canAddIntegration: true, + canManageOpsgenie: true, + ...props, + }, + provide: { + glFeatures: { multipleHttpIntegrationsCustomMapping }, + ...defaultAlertSettingsConfig, + }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, + }); + }; + + const findForm = () => wrapper.find(GlForm); + const findSelect = () => wrapper.find(GlFormSelect); + const findFormSteps = () => wrapper.find(GlCollapse); + const findFormFields = () => wrapper.findAll(GlFormInput); + const findFormToggle = () => wrapper.find(GlToggle); + const findTestPayloadSection = () => wrapper.find(`[id = "test-integration"]`); + const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`); + const findSubmitButton = () => wrapper.find(`[type = "submit"]`); + const findMultiSupportText = () => + wrapper.find(`[data-testid="multi-integrations-not-supported"]`); + const findJsonTestSubmit = () => wrapper.find(`[data-testid="integration-test-and-submit"]`); + const findJsonTextArea = () => wrapper.find(`[id = "test-payload"]`); + const findActionBtn = () => wrapper.find(`[data-testid="payload-action-btn"]`); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('with default values', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the initial template', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('render the initial form with only an integration type dropdown', () => { + expect(findForm().exists()).toBe(true); + expect(findSelect().exists()).toBe(true); + expect(findMultiSupportText().exists()).toBe(false); + expect(findFormSteps().attributes('visible')).toBeUndefined(); + }); + + it('shows the rest of the form when the dropdown is used', async () => { + const options = findSelect().findAll('option'); + await options.at(1).setSelected(); + + await wrapper.vm.$nextTick(); + + expect( + findFormFields() + .at(0) + .isVisible(), + ).toBe(true); + }); + + it('disabled the dropdown and shows help text when multi integrations are not supported', async () => { + createComponent({ props: { canAddIntegration: false } }); + expect(findSelect().attributes('disabled')).toBe('disabled'); + expect(findMultiSupportText().exists()).toBe(true); + }); + }); + + describe('submitting integration form', () => { + it('allows for create-new-integration with the correct form values for HTTP', async () => { + createComponent({}); + + const options = findSelect().findAll('option'); + await options.at(1).setSelected(); + + await findFormFields() + .at(0) + .setValue('Test integration'); + await findFormToggle().trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findSubmitButton().exists()).toBe(true); + expect(findSubmitButton().text()).toBe('Save integration'); + + findForm().trigger('submit'); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('create-new-integration')).toBeTruthy(); + expect(wrapper.emitted('create-new-integration')[0]).toEqual([ + { type: typeSet.http, variables: { name: 'Test integration', active: true } }, + ]); + }); + + it('allows for create-new-integration with the correct form values for PROMETHEUS', async () => { + createComponent({}); + + const options = findSelect().findAll('option'); + await options.at(2).setSelected(); + + await findFormFields() + .at(0) + .setValue('Test integration'); + await findFormFields() + .at(1) + .setValue('https://test.com'); + await findFormToggle().trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findSubmitButton().exists()).toBe(true); + expect(findSubmitButton().text()).toBe('Save integration'); + + findForm().trigger('submit'); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('create-new-integration')).toBeTruthy(); + expect(wrapper.emitted('create-new-integration')[0]).toEqual([ + { type: typeSet.prometheus, variables: { apiUrl: 'https://test.com', active: true } }, + ]); + }); + + it('allows for update-integration with the correct form values for HTTP', async () => { + createComponent({ + data: { + selectedIntegration: typeSet.http, + currentIntegration: { id: '1', name: 'Test integration pre' }, + }, + props: { + loading: false, + }, + }); + + await findFormFields() + .at(0) + .setValue('Test integration post'); + await findFormToggle().trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findSubmitButton().exists()).toBe(true); + expect(findSubmitButton().text()).toBe('Save integration'); + + findForm().trigger('submit'); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update-integration')).toBeTruthy(); + expect(wrapper.emitted('update-integration')[0]).toEqual([ + { type: typeSet.http, variables: { name: 'Test integration post', active: true } }, + ]); + }); + + it('allows for update-integration with the correct form values for PROMETHEUS', async () => { + createComponent({ + data: { + selectedIntegration: typeSet.prometheus, + currentIntegration: { id: '1', apiUrl: 'https://test-pre.com' }, + }, + props: { + loading: false, + }, + }); + + await findFormFields() + .at(0) + .setValue('Test integration'); + await findFormFields() + .at(1) + .setValue('https://test-post.com'); + await findFormToggle().trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findSubmitButton().exists()).toBe(true); + expect(findSubmitButton().text()).toBe('Save integration'); + + findForm().trigger('submit'); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update-integration')).toBeTruthy(); + expect(wrapper.emitted('update-integration')[0]).toEqual([ + { type: typeSet.prometheus, variables: { apiUrl: 'https://test-post.com', active: true } }, + ]); + }); + }); + + describe('submitting the integration with a JSON test payload', () => { + beforeEach(() => { + createComponent({ + data: { + selectedIntegration: typeSet.http, + currentIntegration: { id: '1', name: 'Test' }, + active: true, + }, + props: { + loading: false, + }, + }); + }); + + it('should not allow a user to test invalid JSON', async () => { + jest.useFakeTimers(); + await findJsonTextArea().setValue('Invalid JSON'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + + expect(findJsonTestSubmit().exists()).toBe(true); + expect(findJsonTestSubmit().text()).toBe('Save and test payload'); + expect(findJsonTestSubmit().props('disabled')).toBe(true); + }); + + it('should allow for the form to be automatically saved if the test payload is successfully submitted', async () => { + jest.useFakeTimers(); + await findJsonTextArea().setValue('{ "value": "value" }'); + + jest.runAllTimers(); + await wrapper.vm.$nextTick(); + expect(findJsonTestSubmit().props('disabled')).toBe(false); + }); + }); + + describe('Test payload section for HTTP integration', () => { + beforeEach(() => { + createComponent({ + multipleHttpIntegrationsCustomMapping: true, + props: { + currentIntegration: { + type: typeSet.http, + }, + }, + }); + }); + + describe.each` + active | resetSamplePayloadConfirmed | disabled + ${true} | ${true} | ${undefined} + ${false} | ${true} | ${'disabled'} + ${true} | ${false} | ${'disabled'} + ${false} | ${false} | ${'disabled'} + `('', ({ active, resetSamplePayloadConfirmed, disabled }) => { + const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; + const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled'; + const activeState = active ? 'active' : 'not active'; + + it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => { + wrapper.setData({ + customMapping: { samplePayload: true }, + active, + resetSamplePayloadConfirmed, + }); + await wrapper.vm.$nextTick(); + expect( + findTestPayloadSection() + .find(GlFormTextarea) + .attributes('disabled'), + ).toBe(disabled); + }); + }); + + describe('action buttons for sample payload', () => { + describe.each` + resetSamplePayloadConfirmed | samplePayload | caption + ${false} | ${true} | ${'Edit payload'} + ${true} | ${false} | ${'Submit payload'} + ${true} | ${true} | ${'Submit payload'} + ${false} | ${false} | ${'Submit payload'} + `('', ({ resetSamplePayloadConfirmed, samplePayload, caption }) => { + const samplePayloadMsg = samplePayload ? 'was provided' : 'was not provided'; + const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed'; + + it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => { + wrapper.setData({ + selectedIntegration: typeSet.http, + customMapping: { samplePayload }, + resetSamplePayloadConfirmed, + }); + await wrapper.vm.$nextTick(); + expect(findActionBtn().text()).toBe(caption); + }); + }); + }); + + describe('Parsing payload', () => { + it('displays a toast message on successful parse', async () => { + jest.useFakeTimers(); + wrapper.setData({ + selectedIntegration: typeSet.http, + customMapping: { samplePayload: false }, + }); + await wrapper.vm.$nextTick(); + + findActionBtn().vm.$emit('click'); + jest.advanceTimersByTime(1000); + + await waitForPromises(); + + expect(mockToastShow).toHaveBeenCalledWith( + 'Sample payload has been parsed. You can now map the fields.', + ); + }); + }); + }); + + describe('Mapping builder section', () => { + describe.each` + featureFlag | integrationOption | visible + ${true} | ${1} | ${true} + ${true} | ${2} | ${false} + ${false} | ${1} | ${false} + ${false} | ${2} | ${false} + `('', ({ featureFlag, integrationOption, visible }) => { + const visibleMsg = visible ? 'is rendered' : 'is not rendered'; + const featureFlagMsg = featureFlag ? 'is enabled' : 'is disabled'; + const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus; + + it(`${visibleMsg} when multipleHttpIntegrationsCustomMapping feature flag ${featureFlagMsg} and integration type is ${integrationType}`, async () => { + createComponent({ multipleHttpIntegrationsCustomMapping: featureFlag }); + const options = findSelect().findAll('option'); + options.at(integrationOption).setSelected(); + await wrapper.vm.$nextTick(); + expect(findMappingBuilderSection().exists()).toBe(visible); + }); + }); + }); +}); diff --git a/spec/frontend/alert_settings/alert_settings_form_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_old_spec.js index 6e1ea31ed6a..3d0dfb44d63 100644 --- a/spec/frontend/alert_settings/alert_settings_form_spec.js +++ b/spec/frontend/alerts_settings/alerts_settings_form_old_spec.js @@ -1,20 +1,14 @@ import { shallowMount } from '@vue/test-utils'; import { GlModal, GlAlert } from '@gitlab/ui'; -import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; -import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; +import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form_old.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import { i18n } from '~/alerts_settings/constants'; import service from '~/alerts_settings/services'; +import { defaultAlertSettingsConfig } from './util'; jest.mock('~/alerts_settings/services'); -const PROMETHEUS_URL = '/prometheus/alerts/notify.json'; -const GENERIC_URL = '/alerts/notify.json'; -const KEY = 'abcedfg123'; -const INVALID_URL = 'http://invalid'; -const ACTIVATED = false; - -describe('AlertsSettingsForm', () => { +describe('AlertsSettingsFormOld', () => { let wrapper; const createComponent = ({ methods } = {}, data) => { @@ -23,26 +17,7 @@ describe('AlertsSettingsForm', () => { return { ...data }; }, provide: { - generic: { - authorizationKey: KEY, - formPath: INVALID_URL, - url: GENERIC_URL, - alertsSetupUrl: INVALID_URL, - alertsUsageUrl: INVALID_URL, - activated: ACTIVATED, - }, - prometheus: { - authorizationKey: KEY, - prometheusFormPath: INVALID_URL, - prometheusUrl: PROMETHEUS_URL, - activated: ACTIVATED, - }, - opsgenie: { - opsgenieMvcIsAvailable: true, - formPath: INVALID_URL, - activated: ACTIVATED, - opsgenieMvcTargetUrl: GENERIC_URL, - }, + ...defaultAlertSettingsConfig, }, methods, }); @@ -63,7 +38,10 @@ describe('AlertsSettingsForm', () => { }); afterEach(() => { - wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); describe('with default values', () => { @@ -76,11 +54,6 @@ describe('AlertsSettingsForm', () => { }); }); - it('renders alerts integrations list', () => { - createComponent(); - expect(wrapper.find(IntegrationsList).exists()).toBe(true); - }); - describe('reset key', () => { it('triggers resetKey method', () => { const resetKey = jest.fn(); @@ -96,7 +69,7 @@ describe('AlertsSettingsForm', () => { createComponent( {}, { - authKey: 'newToken', + token: 'newToken', }, ); @@ -140,7 +113,7 @@ describe('AlertsSettingsForm', () => { createComponent( {}, { - selectedEndpoint: 'prometheus', + selectedIntegration: 'PROMETHEUS', }, ); }); @@ -154,7 +127,7 @@ describe('AlertsSettingsForm', () => { }); it('shows the correct default API URL', () => { - expect(findUrl().attributes('value')).toBe(PROMETHEUS_URL); + expect(findUrl().attributes('value')).toBe(defaultAlertSettingsConfig.prometheus.url); }); }); @@ -163,7 +136,7 @@ describe('AlertsSettingsForm', () => { createComponent( {}, { - selectedEndpoint: 'opsgenie', + selectedIntegration: 'OPSGENIE', }, ); }); diff --git a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js new file mode 100644 index 00000000000..7384cf9a095 --- /dev/null +++ b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js @@ -0,0 +1,415 @@ +import VueApollo from 'vue-apollo'; +import { mount, createLocalVue } from '@vue/test-utils'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; +import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue'; +import AlertsSettingsFormOld from '~/alerts_settings/components/alerts_settings_form_old.vue'; +import AlertsSettingsFormNew from '~/alerts_settings/components/alerts_settings_form_new.vue'; +import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; +import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql'; +import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; +import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql'; +import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; +import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql'; +import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql'; +import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql'; +import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql'; +import { typeSet } from '~/alerts_settings/constants'; +import { + ADD_INTEGRATION_ERROR, + RESET_INTEGRATION_TOKEN_ERROR, + UPDATE_INTEGRATION_ERROR, + INTEGRATION_PAYLOAD_TEST_ERROR, + DELETE_INTEGRATION_ERROR, +} from '~/alerts_settings/utils/error_messages'; +import createFlash from '~/flash'; +import { defaultAlertSettingsConfig } from './util'; +import mockIntegrations from './mocks/integrations.json'; +import { + createHttpVariables, + updateHttpVariables, + createPrometheusVariables, + updatePrometheusVariables, + ID, + errorMsg, + getIntegrationsQueryResponse, + destroyIntegrationResponse, + integrationToDestroy, + destroyIntegrationResponseWithErrors, +} from './mocks/apollo_mock'; + +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('AlertsSettingsWrapper', () => { + let wrapper; + let fakeApollo; + let destroyIntegrationHandler; + useMockIntersectionObserver(); + + const findLoader = () => wrapper.find(IntegrationsList).find(GlLoadingIcon); + const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr'); + + async function destroyHttpIntegration(localWrapper) { + await jest.runOnlyPendingTimers(); + await localWrapper.vm.$nextTick(); + + localWrapper + .find(IntegrationsList) + .vm.$emit('delete-integration', { id: integrationToDestroy.id }); + } + + async function awaitApolloDomMock() { + await wrapper.vm.$nextTick(); // kick off the DOM update + await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises) + await wrapper.vm.$nextTick(); // kick off the DOM update for flash + } + + const createComponent = ({ data = {}, provide = {}, loading = false } = {}) => { + wrapper = mount(AlertsSettingsWrapper, { + data() { + return { ...data }; + }, + provide: { + ...defaultAlertSettingsConfig, + glFeatures: { httpIntegrationsList: false }, + ...provide, + }, + mocks: { + $apollo: { + mutate: jest.fn(), + query: jest.fn(), + queries: { + integrations: { + loading, + }, + }, + }, + }, + }); + }; + + function createComponentWithApollo({ + destroyHandler = jest.fn().mockResolvedValue(destroyIntegrationResponse), + } = {}) { + localVue.use(VueApollo); + destroyIntegrationHandler = destroyHandler; + + const requestHandlers = [ + [getIntegrationsQuery, jest.fn().mockResolvedValue(getIntegrationsQueryResponse)], + [destroyHttpIntegrationMutation, destroyIntegrationHandler], + ]; + + fakeApollo = createMockApollo(requestHandlers); + + wrapper = mount(AlertsSettingsWrapper, { + localVue, + apolloProvider: fakeApollo, + provide: { + ...defaultAlertSettingsConfig, + glFeatures: { httpIntegrationsList: true }, + }, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('with httpIntegrationsList feature flag disabled', () => { + it('renders data driven alerts integrations list and old form by default', () => { + createComponent(); + expect(wrapper.find(IntegrationsList).exists()).toBe(true); + expect(wrapper.find(AlertsSettingsFormOld).exists()).toBe(true); + expect(wrapper.find(AlertsSettingsFormNew).exists()).toBe(false); + }); + }); + + describe('with httpIntegrationsList feature flag enabled', () => { + it('renders the GraphQL alerts integrations list and new form', () => { + createComponent({ provide: { glFeatures: { httpIntegrationsList: true } } }); + expect(wrapper.find(IntegrationsList).exists()).toBe(true); + expect(wrapper.find(AlertsSettingsFormOld).exists()).toBe(false); + expect(wrapper.find(AlertsSettingsFormNew).exists()).toBe(true); + }); + + it('uses a loading state inside the IntegrationsList table', () => { + createComponent({ + data: { integrations: {} }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: true, + }); + expect(wrapper.find(IntegrationsList).exists()).toBe(true); + expect(findLoader().exists()).toBe(true); + }); + + it('renders the IntegrationsList table using the API data', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + expect(findLoader().exists()).toBe(false); + expect(findIntegrations()).toHaveLength(mockIntegrations.length); + }); + + it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { createHttpIntegrationMutation: { integration: { id: '1' } } }, + }); + wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', { + type: typeSet.http, + variables: createHttpVariables, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: createHttpIntegrationMutation, + update: expect.anything(), + variables: createHttpVariables, + }); + }); + + it('calls `$apollo.mutate` with `updateHttpIntegrationMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { updateHttpIntegrationMutation: { integration: { id: '1' } } }, + }); + wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', { + type: typeSet.http, + variables: updateHttpVariables, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateHttpIntegrationMutation, + variables: updateHttpVariables, + }); + }); + + it('calls `$apollo.mutate` with `resetHttpTokenMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { resetHttpTokenMutation: { integration: { id: '1' } } }, + }); + wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', { + type: typeSet.http, + variables: { id: ID }, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: resetHttpTokenMutation, + variables: { + id: ID, + }, + }); + }); + + it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } }, + }); + wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', { + type: typeSet.prometheus, + variables: createPrometheusVariables, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: createPrometheusIntegrationMutation, + update: expect.anything(), + variables: createPrometheusVariables, + }); + }); + + it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { updatePrometheusIntegrationMutation: { integration: { id: '2' } } }, + }); + wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', { + type: typeSet.prometheus, + variables: updatePrometheusVariables, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updatePrometheusIntegrationMutation, + variables: updatePrometheusVariables, + }); + }); + + it('calls `$apollo.mutate` with `resetPrometheusTokenMutation`', () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { resetPrometheusTokenMutation: { integration: { id: '1' } } }, + }); + wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', { + type: typeSet.prometheus, + variables: { id: ID }, + }); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: resetPrometheusTokenMutation, + variables: { + id: ID, + }, + }); + }); + + it('shows an error alert when integration creation fails ', async () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR); + wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {}); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: ADD_INTEGRATION_ERROR }); + }); + + it('shows an error alert when integration token reset fails ', async () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR); + + wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {}); + + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR }); + }); + + it('shows an error alert when integration update fails ', async () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); + + wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {}); + + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR }); + }); + + it('shows an error alert when integration test payload fails ', async () => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true } }, + loading: false, + }); + + wrapper.find(AlertsSettingsFormNew).vm.$emit('test-payload-failure'); + + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + expect(createFlash).toHaveBeenCalledTimes(1); + }); + }); + + describe('with mocked Apollo client', () => { + it('has a selection of integrations loaded via the getIntegrationsQuery', async () => { + createComponentWithApollo(); + + await jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + expect(findIntegrations()).toHaveLength(4); + }); + + it('calls a mutation with correct parameters and destroys a integration', async () => { + createComponentWithApollo(); + + await destroyHttpIntegration(wrapper); + + expect(destroyIntegrationHandler).toHaveBeenCalled(); + + await wrapper.vm.$nextTick(); + + expect(findIntegrations()).toHaveLength(3); + }); + + it('displays flash if mutation had a recoverable error', async () => { + createComponentWithApollo({ + destroyHandler: jest.fn().mockResolvedValue(destroyIntegrationResponseWithErrors), + }); + + await destroyHttpIntegration(wrapper); + await awaitApolloDomMock(); + + expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' }); + }); + + it('displays flash if mutation had a non-recoverable error', async () => { + createComponentWithApollo({ + destroyHandler: jest.fn().mockRejectedValue('Error'), + }); + + await destroyHttpIntegration(wrapper); + await awaitApolloDomMock(); + + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_INTEGRATION_ERROR, + }); + }); + }); + + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + describe('Opsgenie integration', () => { + it.each([true, false])('it shows/hides the alert when opsgenie is %s', active => { + createComponent({ + data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] }, + provide: { glFeatures: { httpIntegrationsList: true }, opsgenie: { active } }, + loading: false, + }); + + expect(wrapper.find(GlAlert).exists()).toBe(active); + }); + }); +}); diff --git a/spec/frontend/alerts_settings/mocks/apollo_mock.js b/spec/frontend/alerts_settings/mocks/apollo_mock.js new file mode 100644 index 00000000000..e0eba1e8421 --- /dev/null +++ b/spec/frontend/alerts_settings/mocks/apollo_mock.js @@ -0,0 +1,123 @@ +const projectPath = ''; +export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7'; +export const errorMsg = 'Something went wrong'; + +export const createHttpVariables = { + name: 'Test Pre', + active: true, + projectPath, +}; + +export const updateHttpVariables = { + name: 'Test Pre', + active: true, + id: ID, +}; + +export const createPrometheusVariables = { + apiUrl: 'https://test-pre.com', + active: true, + projectPath, +}; + +export const updatePrometheusVariables = { + apiUrl: 'https://test-pre.com', + active: true, + id: ID, +}; + +export const getIntegrationsQueryResponse = { + data: { + project: { + alertManagementIntegrations: { + nodes: [ + { + id: '37', + type: 'HTTP', + active: true, + name: 'Test 5', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', + token: '89eb01df471d990ff5162a1c640408cf', + apiUrl: null, + }, + { + id: '41', + type: 'HTTP', + active: true, + name: 'Test 9999', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-9999/b78a566e1776cfc2.json', + token: 'f7579aa03844e07af3b1f0fca3f79f81', + apiUrl: null, + }, + { + id: '40', + type: 'HTTP', + active: true, + name: 'Test 6', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-6/3e828ae28a240222.json', + token: '6536102a607a5dd74fcdde921f2349ee', + apiUrl: null, + }, + { + id: '12', + type: 'PROMETHEUS', + active: false, + name: 'Prometheus', + url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/prometheus/alerts/notify.json', + token: '256f687c6225aa5d6ee50c3d68120c4c', + apiUrl: 'https://localhost.ieeeesassadasasa', + }, + ], + }, + }, + }, +}; + +export const integrationToDestroy = { + id: '37', + type: 'HTTP', + active: true, + name: 'Test 5', + url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', + token: '89eb01df471d990ff5162a1c640408cf', + apiUrl: null, +}; + +export const destroyIntegrationResponse = { + data: { + httpIntegrationDestroy: { + errors: [], + integration: { + id: '37', + type: 'HTTP', + active: true, + name: 'Test 5', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', + token: '89eb01df471d990ff5162a1c640408cf', + apiUrl: null, + }, + }, + }, +}; + +export const destroyIntegrationResponseWithErrors = { + data: { + httpIntegrationDestroy: { + errors: ['Houston, we have a problem'], + integration: { + id: '37', + type: 'HTTP', + active: true, + name: 'Test 5', + url: + 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json', + token: '89eb01df471d990ff5162a1c640408cf', + apiUrl: null, + }, + }, + }, +}; diff --git a/spec/frontend/alerts_settings/mocks/integrations.json b/spec/frontend/alerts_settings/mocks/integrations.json new file mode 100644 index 00000000000..b1284fc55a2 --- /dev/null +++ b/spec/frontend/alerts_settings/mocks/integrations.json @@ -0,0 +1,38 @@ +[ + { + "id": "gid://gitlab/AlertManagement::HttpIntegration/7", + "type": "HTTP", + "active": true, + "name": "test", + "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/eddd36969b2d3d6a.json", + "token": "7eb24af194116411ec8d66b58c6b0d2e", + "apiUrl": null + }, + { + "id": "gid://gitlab/AlertManagement::HttpIntegration/6", + "type": "HTTP", + "active": false, + "name": "test", + "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/abce123.json", + "token": "8639e0ce06c731b00ee3e8dcdfd14fe0", + "apiUrl": null + }, + { + "id": "gid://gitlab/AlertManagement::HttpIntegration/5", + "type": "HTTP", + "active": false, + "name": "test", + "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/bcd64c85f918a2e2.json", + "token": "5c8101533d970a55d5c105f8abff2192", + "apiUrl": null + }, + { + "id": "gid://gitlab/PrometheusService/12", + "type": "PROMETHEUS", + "active": true, + "name": "Prometheus", + "url": "http://192.168.1.152:3000/root/autodevops/prometheus/alerts/notify.json", + "token": "0b18c37caa8fe980799b349916fe5ddf", + "apiUrl": "https://another-url-2.com" + } +] diff --git a/spec/frontend/alerts_settings/util.js b/spec/frontend/alerts_settings/util.js new file mode 100644 index 00000000000..f9f9b69791e --- /dev/null +++ b/spec/frontend/alerts_settings/util.js @@ -0,0 +1,30 @@ +const PROMETHEUS_URL = '/prometheus/alerts/notify.json'; +const GENERIC_URL = '/alerts/notify.json'; +const KEY = 'abcedfg123'; +const INVALID_URL = 'http://invalid'; +const ACTIVE = false; + +export const defaultAlertSettingsConfig = { + generic: { + authorizationKey: KEY, + formPath: INVALID_URL, + url: GENERIC_URL, + alertsSetupUrl: INVALID_URL, + alertsUsageUrl: INVALID_URL, + active: ACTIVE, + }, + prometheus: { + authorizationKey: KEY, + prometheusFormPath: INVALID_URL, + url: PROMETHEUS_URL, + active: ACTIVE, + }, + opsgenie: { + opsgenieMvcIsAvailable: true, + formPath: INVALID_URL, + active: ACTIVE, + opsgenieMvcTargetUrl: GENERIC_URL, + }, + projectPath: '', + multiIntegrations: true, +}; diff --git a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js index 2e4eaf3fc96..98eabd577ee 100644 --- a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js +++ b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js @@ -1,30 +1,36 @@ -const defaultPageInfo = { hasPreviousPage: false, startCursor: null, endCursor: null }; +const defaultPageInfo = { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, +}; -export function getApolloResponse(options = {}) { - const { - pipelinesTotal = [], - pipelinesSucceeded = [], - pipelinesFailed = [], - pipelinesCanceled = [], - pipelinesSkipped = [], - hasNextPage = false, - } = options; - return { - data: { - pipelinesTotal: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesTotal }, - pipelinesSucceeded: { - pageInfo: { ...defaultPageInfo, hasNextPage }, - nodes: pipelinesSucceeded, - }, - pipelinesFailed: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesFailed }, - pipelinesCanceled: { - pageInfo: { ...defaultPageInfo, hasNextPage }, - nodes: pipelinesCanceled, - }, - pipelinesSkipped: { - pageInfo: { ...defaultPageInfo, hasNextPage }, - nodes: pipelinesSkipped, - }, +export const mockApolloResponse = ({ hasNextPage = false, key, data }) => ({ + data: { + [key]: { + pageInfo: { ...defaultPageInfo, hasNextPage }, + nodes: data, }, - }; -} + }, +}); + +export const mockQueryResponse = ({ key, data = [], loading = false, additionalData = [] }) => { + const hasNextPage = Boolean(additionalData.length); + const response = mockApolloResponse({ hasNextPage, key, data }); + if (loading) { + return jest.fn().mockReturnValue(new Promise(() => {})); + } + if (hasNextPage) { + return jest + .fn() + .mockResolvedValueOnce(response) + .mockResolvedValueOnce( + mockApolloResponse({ + hasNextPage: false, + key, + data: additionalData, + }), + ); + } + return jest.fn().mockResolvedValue(response); +}; diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap b/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap new file mode 100644 index 00000000000..29bcd5f223b --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InstanceStatisticsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = ` +Array [ + Object { + "data": Array [ + Array [ + "2020-07-01", + 41, + ], + Array [ + "2020-06-01", + 22, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Mock Query", + }, +] +`; + +exports[`InstanceStatisticsCountChart with data passes the data to the line chart 1`] = ` +Array [ + Object { + "data": Array [ + Array [ + "2020-07-01", + 41, + ], + Array [ + "2020-06-01", + 22, + ], + ], + "name": "Mock Query", + }, +] +`; diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap b/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap deleted file mode 100644 index 0b3b685a9f2..00000000000 --- a/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap +++ /dev/null @@ -1,161 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PipelinesChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = ` -Array [ - Object { - "data": Array [ - Array [ - "2020-06-01", - 21, - ], - Array [ - "2020-07-01", - 10, - ], - Array [ - "2020-08-01", - 5, - ], - ], - "name": "Total", - }, - Object { - "data": Array [ - Array [ - "2020-06-01", - 21, - ], - Array [ - "2020-07-01", - 10, - ], - Array [ - "2020-08-01", - 5, - ], - ], - "name": "Succeeded", - }, - Object { - "data": Array [ - Array [ - "2020-06-01", - 22, - ], - Array [ - "2020-07-01", - 41, - ], - Array [ - "2020-08-01", - 5, - ], - ], - "name": "Failed", - }, - Object { - "data": Array [ - Array [ - "2020-06-01", - 21, - ], - Array [ - "2020-07-01", - 10, - ], - Array [ - "2020-08-01", - 5, - ], - ], - "name": "Canceled", - }, - Object { - "data": Array [ - Array [ - "2020-06-01", - 21, - ], - Array [ - "2020-07-01", - 10, - ], - Array [ - "2020-08-01", - 5, - ], - ], - "name": "Skipped", - }, -] -`; - -exports[`PipelinesChart with data passes the data to the line chart 1`] = ` -Array [ - Object { - "data": Array [ - Array [ - "2020-06-01", - 22, - ], - Array [ - "2020-07-01", - 41, - ], - ], - "name": "Total", - }, - Object { - "data": Array [ - Array [ - "2020-06-01", - 21, - ], - Array [ - "2020-07-01", - 10, - ], - ], - "name": "Succeeded", - }, - Object { - "data": Array [ - Array [ - "2020-06-01", - 21, - ], - Array [ - "2020-07-01", - 10, - ], - ], - "name": "Failed", - }, - Object { - "data": Array [ - Array [ - "2020-06-01", - 22, - ], - Array [ - "2020-07-01", - 41, - ], - ], - "name": "Canceled", - }, - Object { - "data": Array [ - Array [ - "2020-06-01", - 22, - ], - Array [ - "2020-07-01", - 41, - ], - ], - "name": "Skipped", - }, -] -`; diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js index df13c9f82a9..8ac663b3046 100644 --- a/spec/frontend/analytics/instance_statistics/components/app_spec.js +++ b/spec/frontend/analytics/instance_statistics/components/app_spec.js @@ -1,8 +1,9 @@ import { shallowMount } from '@vue/test-utils'; import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue'; import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue'; -import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue'; +import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue'; import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; +import ProjectsAndGroupsChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue'; describe('InstanceStatisticsApp', () => { let wrapper; @@ -24,11 +25,21 @@ describe('InstanceStatisticsApp', () => { expect(wrapper.find(InstanceCounts).exists()).toBe(true); }); - it('displays the pipelines chart component', () => { - expect(wrapper.find(PipelinesChart).exists()).toBe(true); + ['Pipelines', 'Issues & Merge Requests'].forEach(instance => { + it(`displays the ${instance} chart`, () => { + const chartTitles = wrapper + .findAll(InstanceStatisticsCountChart) + .wrappers.map(chartComponent => chartComponent.props('chartTitle')); + + expect(chartTitles).toContain(instance); + }); }); it('displays the users chart component', () => { expect(wrapper.find(UsersChart).exists()).toBe(true); }); + + it('displays the projects and groups chart component', () => { + expect(wrapper.find(ProjectsAndGroupsChart).exists()).toBe(true); + }); }); diff --git a/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js new file mode 100644 index 00000000000..275a84988f8 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js @@ -0,0 +1,177 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue'; +import statsQuery from '~/analytics/instance_statistics/graphql/queries/instance_count.query.graphql'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { mockCountsData1 } from '../mock_data'; +import { mockQueryResponse, mockApolloResponse } from '../apollo_mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const loadChartErrorMessage = 'My load error message'; +const noDataMessage = 'My no data message'; + +const queryResponseDataKey = 'instanceStatisticsMeasurements'; +const identifier = 'MOCK_QUERY'; +const mockQueryConfig = { + identifier, + title: 'Mock Query', + query: statsQuery, + loadError: 'Failed to load mock query data', +}; + +const mockChartConfig = { + loadChartErrorMessage, + noDataMessage, + chartTitle: 'Foo', + yAxisTitle: 'Bar', + xAxisTitle: 'Baz', + queries: [mockQueryConfig], +}; + +describe('InstanceStatisticsCountChart', () => { + let wrapper; + let queryHandler; + + const createComponent = ({ responseHandler }) => { + return shallowMount(InstanceStatisticsCountChart, { + localVue, + apolloProvider: createMockApollo([[statsQuery, responseHandler]]), + propsData: { ...mockChartConfig }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findLoader = () => wrapper.find(ChartSkeletonLoader); + const findChart = () => wrapper.find(GlLineChart); + const findAlert = () => wrapper.find(GlAlert); + + describe('while loading', () => { + beforeEach(() => { + queryHandler = mockQueryResponse({ key: queryResponseDataKey, loading: true }); + wrapper = createComponent({ responseHandler: queryHandler }); + }); + + it('requests data', () => { + expect(queryHandler).toBeCalledTimes(1); + }); + + it('displays the skeleton loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('hides the chart', () => { + expect(findChart().exists()).toBe(false); + }); + + it('does not show an error', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('without data', () => { + beforeEach(() => { + queryHandler = mockQueryResponse({ key: queryResponseDataKey, data: [] }); + wrapper = createComponent({ responseHandler: queryHandler }); + }); + + it('renders an no data message', () => { + expect(findAlert().text()).toBe(noDataMessage); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('with data', () => { + beforeEach(() => { + queryHandler = mockQueryResponse({ key: queryResponseDataKey, data: mockCountsData1 }); + wrapper = createComponent({ responseHandler: queryHandler }); + }); + + it('requests data', () => { + expect(queryHandler).toBeCalledTimes(1); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(true); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toMatchSnapshot(); + }); + + it('does not show an error', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('when fetching more data', () => { + const recordedAt = '2020-08-01'; + describe('when the fetchMore query returns data', () => { + beforeEach(async () => { + const newData = [{ recordedAt, count: 5 }]; + queryHandler = mockQueryResponse({ + key: queryResponseDataKey, + data: mockCountsData1, + additionalData: newData, + }); + + wrapper = createComponent({ responseHandler: queryHandler }); + await wrapper.vm.$nextTick(); + }); + + it('requests data twice', () => { + expect(queryHandler).toBeCalledTimes(2); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toMatchSnapshot(); + }); + }); + + describe('when the fetchMore query throws an error', () => { + beforeEach(async () => { + queryHandler = jest.fn().mockResolvedValueOnce( + mockApolloResponse({ + key: queryResponseDataKey, + data: mockCountsData1, + hasNextPage: true, + }), + ); + + wrapper = createComponent({ responseHandler: queryHandler }); + jest + .spyOn(wrapper.vm.$apollo.queries[identifier], 'fetchMore') + .mockImplementation(jest.fn().mockRejectedValue()); + + await wrapper.vm.$nextTick(); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries[identifier].fetchMore).toHaveBeenCalledTimes(1); + }); + + it('show an error message', () => { + expect(findAlert().text()).toBe(loadChartErrorMessage); + }); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js deleted file mode 100644 index a06d66f783e..00000000000 --- a/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js +++ /dev/null @@ -1,189 +0,0 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlLineChart } from '@gitlab/ui/dist/charts'; -import { GlAlert } from '@gitlab/ui'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'jest/helpers/mock_apollo_helper'; -import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue'; -import pipelinesStatsQuery from '~/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql'; -import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; -import { mockCountsData1, mockCountsData2 } from '../mock_data'; -import { getApolloResponse } from '../apollo_mock_data'; - -const localVue = createLocalVue(); -localVue.use(VueApollo); - -describe('PipelinesChart', () => { - let wrapper; - let queryHandler; - - const createApolloProvider = pipelineStatsHandler => { - return createMockApollo([[pipelinesStatsQuery, pipelineStatsHandler]]); - }; - - const createComponent = apolloProvider => { - return shallowMount(PipelinesChart, { - localVue, - apolloProvider, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findLoader = () => wrapper.find(ChartSkeletonLoader); - const findChart = () => wrapper.find(GlLineChart); - const findAlert = () => wrapper.find(GlAlert); - - describe('while loading', () => { - beforeEach(() => { - queryHandler = jest.fn().mockReturnValue(new Promise(() => {})); - const apolloProvider = createApolloProvider(queryHandler); - wrapper = createComponent(apolloProvider); - }); - - it('requests data', () => { - expect(queryHandler).toBeCalledTimes(1); - }); - - it('displays the skeleton loader', () => { - expect(findLoader().exists()).toBe(true); - }); - - it('hides the chart', () => { - expect(findChart().exists()).toBe(false); - }); - - it('does not show an error', () => { - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('without data', () => { - beforeEach(() => { - const emptyResponse = getApolloResponse(); - queryHandler = jest.fn().mockResolvedValue(emptyResponse); - const apolloProvider = createApolloProvider(queryHandler); - wrapper = createComponent(apolloProvider); - }); - - it('renders an no data message', () => { - expect(findAlert().text()).toBe('There is no data available.'); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('renders the chart', () => { - expect(findChart().exists()).toBe(false); - }); - }); - - describe('with data', () => { - beforeEach(() => { - const response = getApolloResponse({ - pipelinesTotal: mockCountsData1, - pipelinesSucceeded: mockCountsData2, - pipelinesFailed: mockCountsData2, - pipelinesCanceled: mockCountsData1, - pipelinesSkipped: mockCountsData1, - }); - queryHandler = jest.fn().mockResolvedValue(response); - const apolloProvider = createApolloProvider(queryHandler); - wrapper = createComponent(apolloProvider); - }); - - it('requests data', () => { - expect(queryHandler).toBeCalledTimes(1); - }); - - it('hides the skeleton loader', () => { - expect(findLoader().exists()).toBe(false); - }); - - it('renders the chart', () => { - expect(findChart().exists()).toBe(true); - }); - - it('passes the data to the line chart', () => { - expect(findChart().props('data')).toMatchSnapshot(); - }); - - it('does not show an error', () => { - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('when fetching more data', () => { - const recordedAt = '2020-08-01'; - describe('when the fetchMore query returns data', () => { - beforeEach(async () => { - const newData = { recordedAt, count: 5 }; - const firstResponse = getApolloResponse({ - pipelinesTotal: mockCountsData2, - pipelinesSucceeded: mockCountsData2, - pipelinesFailed: mockCountsData1, - pipelinesCanceled: mockCountsData2, - pipelinesSkipped: mockCountsData2, - hasNextPage: true, - }); - const secondResponse = getApolloResponse({ - pipelinesTotal: [newData], - pipelinesSucceeded: [newData], - pipelinesFailed: [newData], - pipelinesCanceled: [newData], - pipelinesSkipped: [newData], - hasNextPage: false, - }); - queryHandler = jest - .fn() - .mockResolvedValueOnce(firstResponse) - .mockResolvedValueOnce(secondResponse); - const apolloProvider = createApolloProvider(queryHandler); - wrapper = createComponent(apolloProvider); - - await wrapper.vm.$nextTick(); - }); - - it('requests data twice', () => { - expect(queryHandler).toBeCalledTimes(2); - }); - - it('passes the data to the line chart', () => { - expect(findChart().props('data')).toMatchSnapshot(); - }); - }); - - describe('when the fetchMore query throws an error', () => { - beforeEach(async () => { - const response = getApolloResponse({ - pipelinesTotal: mockCountsData2, - pipelinesSucceeded: mockCountsData2, - pipelinesFailed: mockCountsData1, - pipelinesCanceled: mockCountsData2, - pipelinesSkipped: mockCountsData2, - hasNextPage: true, - }); - queryHandler = jest.fn().mockResolvedValue(response); - const apolloProvider = createApolloProvider(queryHandler); - wrapper = createComponent(apolloProvider); - jest - .spyOn(wrapper.vm.$apollo.queries.pipelineStats, 'fetchMore') - .mockImplementation(jest.fn().mockRejectedValue()); - await wrapper.vm.$nextTick(); - }); - - it('calls fetchMore', () => { - expect(wrapper.vm.$apollo.queries.pipelineStats.fetchMore).toHaveBeenCalledTimes(1); - }); - - it('show an error message', () => { - expect(findAlert().text()).toBe( - 'Could not load the pipelines chart. Please refresh the page to try again.', - ); - }); - }); - }); -}); diff --git a/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js new file mode 100644 index 00000000000..d9f42430aa8 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js @@ -0,0 +1,216 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import { useFakeDate } from 'helpers/fake_date'; +import ProjectsAndGroupChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import projectsQuery from '~/analytics/instance_statistics/graphql/queries/projects.query.graphql'; +import groupsQuery from '~/analytics/instance_statistics/graphql/queries/groups.query.graphql'; +import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data'; +import { mockQueryResponse } from '../apollo_mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('ProjectsAndGroupChart', () => { + let wrapper; + let queryResponses = { projects: null, groups: null }; + const mockAdditionalData = [{ recordedAt: '2020-07-21', count: 5 }]; + + const createComponent = ({ + loadingError = false, + projects = [], + groups = [], + projectsLoading = false, + groupsLoading = false, + projectsAdditionalData = [], + groupsAdditionalData = [], + } = {}) => { + queryResponses = { + projects: mockQueryResponse({ + key: 'projects', + data: projects, + loading: projectsLoading, + additionalData: projectsAdditionalData, + }), + groups: mockQueryResponse({ + key: 'groups', + data: groups, + loading: groupsLoading, + additionalData: groupsAdditionalData, + }), + }; + + return shallowMount(ProjectsAndGroupChart, { + props: { + startDate: useFakeDate(2020, 9, 26), + endDate: useFakeDate(2020, 10, 1), + totalDataPoints: mockCountsData2.length, + }, + localVue, + apolloProvider: createMockApollo([ + [projectsQuery, queryResponses.projects], + [groupsQuery, queryResponses.groups], + ]), + data() { + return { loadingError }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + queryResponses = { + projects: null, + groups: null, + }; + }); + + const findLoader = () => wrapper.find(ChartSkeletonLoader); + const findAlert = () => wrapper.find(GlAlert); + const findChart = () => wrapper.find(GlLineChart); + + describe('while loading', () => { + beforeEach(() => { + wrapper = createComponent({ projectsLoading: true, groupsLoading: true }); + }); + + it('displays the skeleton loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('hides the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('while loading 1 data set', () => { + beforeEach(async () => { + wrapper = createComponent({ + projects: mockCountsData2, + groupsLoading: true, + }); + + await wrapper.vm.$nextTick(); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(true); + }); + }); + + describe('without data', () => { + beforeEach(async () => { + wrapper = createComponent({ projects: [] }); + await wrapper.vm.$nextTick(); + }); + + it('renders a no data message', () => { + expect(findAlert().text()).toBe('No data available.'); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('does not render the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('with data', () => { + beforeEach(async () => { + wrapper = createComponent({ projects: mockCountsData2 }); + await wrapper.vm.$nextTick(); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(true); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toEqual([ + { data: roundedSortedCountsMonthlyChartData2, name: 'Total projects' }, + { data: [], name: 'Total groups' }, + ]); + }); + }); + + describe('with errors', () => { + beforeEach(async () => { + wrapper = createComponent({ loadingError: true }); + await wrapper.vm.$nextTick(); + }); + + it('renders an error message', () => { + expect(findAlert().text()).toBe('No data available.'); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('hides the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe.each` + metric | loadingState | newData + ${'projects'} | ${{ projectsAdditionalData: mockAdditionalData }} | ${{ projects: mockCountsData2 }} + ${'groups'} | ${{ groupsAdditionalData: mockAdditionalData }} | ${{ groups: mockCountsData2 }} + `('$metric - fetchMore', ({ metric, loadingState, newData }) => { + describe('when the fetchMore query returns data', () => { + beforeEach(async () => { + wrapper = createComponent({ + ...loadingState, + ...newData, + }); + + jest.spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore'); + await wrapper.vm.$nextTick(); + }); + + it('requests data twice', () => { + expect(queryResponses[metric]).toBeCalledTimes(2); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the fetchMore query throws an error', () => { + beforeEach(() => { + wrapper = createComponent({ + ...loadingState, + ...newData, + }); + + jest + .spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore') + .mockImplementation(jest.fn().mockRejectedValue()); + return wrapper.vm.$nextTick(); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1); + }); + + it('renders an error message', () => { + expect(findAlert().text()).toBe('No data available.'); + }); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js index 7509c1e6626..6ed9d203f3d 100644 --- a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js +++ b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js @@ -7,7 +7,12 @@ import { useFakeDate } from 'helpers/fake_date'; import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql'; -import { mockCountsData2, roundedSortedCountsMonthlyChartData2, mockPageInfo } from '../mock_data'; +import { + mockCountsData1, + mockCountsData2, + roundedSortedCountsMonthlyChartData2, +} from '../mock_data'; +import { mockQueryResponse } from '../apollo_mock_data'; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -16,43 +21,13 @@ describe('UsersChart', () => { let wrapper; let queryHandler; - const mockApolloResponse = ({ loading = false, hasNextPage = false, users }) => ({ - data: { - users: { - pageInfo: { ...mockPageInfo, hasNextPage }, - nodes: users, - loading, - }, - }, - }); - - const mockQueryResponse = ({ users, loading = false, hasNextPage = false }) => { - const apolloQueryResponse = mockApolloResponse({ loading, hasNextPage, users }); - if (loading) { - return jest.fn().mockReturnValue(new Promise(() => {})); - } - if (hasNextPage) { - return jest - .fn() - .mockResolvedValueOnce(apolloQueryResponse) - .mockResolvedValueOnce( - mockApolloResponse({ - loading, - hasNextPage: false, - users: [{ recordedAt: '2020-07-21', count: 5 }], - }), - ); - } - return jest.fn().mockResolvedValue(apolloQueryResponse); - }; - const createComponent = ({ loadingError = false, loading = false, users = [], - hasNextPage = false, + additionalData = [], } = {}) => { - queryHandler = mockQueryResponse({ users, loading, hasNextPage }); + queryHandler = mockQueryResponse({ key: 'users', data: users, loading, additionalData }); return shallowMount(UsersChart, { props: { @@ -157,7 +132,7 @@ describe('UsersChart', () => { beforeEach(async () => { wrapper = createComponent({ users: mockCountsData2, - hasNextPage: true, + additionalData: mockCountsData1, }); jest.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore'); @@ -177,7 +152,7 @@ describe('UsersChart', () => { beforeEach(() => { wrapper = createComponent({ users: mockCountsData2, - hasNextPage: true, + additionalData: mockCountsData1, }); jest diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/instance_statistics/mock_data.js index b737db4c55f..e86e552a952 100644 --- a/spec/frontend/analytics/instance_statistics/mock_data.js +++ b/spec/frontend/analytics/instance_statistics/mock_data.js @@ -33,10 +33,3 @@ export const roundedSortedCountsMonthlyChartData2 = [ ['2020-06-01', 21], // average of 2020-06-x items ['2020-07-01', 10], // average of 2020-07-x items ]; - -export const mockPageInfo = { - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - endCursor: null, -}; diff --git a/spec/frontend/analytics/instance_statistics/utils_spec.js b/spec/frontend/analytics/instance_statistics/utils_spec.js index d480238419b..3fd89c7f740 100644 --- a/spec/frontend/analytics/instance_statistics/utils_spec.js +++ b/spec/frontend/analytics/instance_statistics/utils_spec.js @@ -1,7 +1,7 @@ import { getAverageByMonth, - extractValues, - sortByDate, + getEarliestDate, + generateDataKeys, } from '~/analytics/instance_statistics/utils'; import { mockCountsData1, @@ -44,41 +44,38 @@ describe('getAverageByMonth', () => { }); }); -describe('extractValues', () => { - it('extracts only requested values', () => { - const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' }; - expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({ bazBar: 'quis' }); +describe('getEarliestDate', () => { + it('returns the date of the final item in the array', () => { + expect(getEarliestDate(mockCountsData1)).toBe('2020-06-12'); }); - it('is able to extract multiple values', () => { - const data = { - fooBar: { baz: 'quis' }, - fooBaz: { baz: 'quis' }, - fooQuis: { baz: 'quis' }, - }; - expect(extractValues(data, ['fooBar', 'fooBaz', 'fooQuis'], 'foo', 'baz')).toEqual({ - bazBar: 'quis', - bazBaz: 'quis', - bazQuis: 'quis', - }); - }); - - it('returns empty data set when keys are not found', () => { - const data = { foo: { baz: 'quis' }, ignored: 'ignored' }; - expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({}); + it('returns null for an empty array', () => { + expect(getEarliestDate([])).toBeNull(); }); - it('returns empty data when params are missing', () => { - expect(extractValues()).toEqual({}); + it("returns null if the array has data but `recordedAt` isn't defined", () => { + expect( + getEarliestDate(mockCountsData1.map(({ recordedAt: date, ...rest }) => ({ date, ...rest }))), + ).toBeNull(); }); }); -describe('sortByDate', () => { - it('sorts the array by date', () => { - expect(sortByDate(mockCountsData1)).toStrictEqual([...mockCountsData1].reverse()); +describe('generateDataKeys', () => { + const fakeQueries = [ + { identifier: 'from' }, + { identifier: 'first' }, + { identifier: 'to' }, + { identifier: 'last' }, + ]; + + const defaultValue = 'default value'; + const res = generateDataKeys(fakeQueries, defaultValue); + + it('extracts each query identifier and sets them as object keys', () => { + expect(Object.keys(res)).toEqual(['from', 'first', 'to', 'last']); }); - it('does not modify the original array', () => { - expect(sortByDate(countsMonthlyChartData1)).not.toBe(countsMonthlyChartData1); + it('sets every value to the `defaultValue` provided', () => { + expect(Object.values(res)).toEqual(Array(fakeQueries.length).fill(defaultValue)); }); }); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 9924525929b..724d33922a1 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -118,6 +118,24 @@ describe('Api', () => { }); }); + describe('container registry', () => { + describe('containerRegistryDetails', () => { + it('fetch container registry details', async () => { + const expectedUrl = `foo`; + const apiResponse = {}; + + jest.spyOn(axios, 'get'); + jest.spyOn(Api, 'buildUrl').mockReturnValueOnce(expectedUrl); + mock.onGet(expectedUrl).replyOnce(httpStatus.OK, apiResponse); + + const { data } = await Api.containerRegistryDetails(1); + + expect(data).toEqual(apiResponse); + expect(axios.get).toHaveBeenCalledWith(expectedUrl, {}); + }); + }); + }); + describe('group', () => { it('fetches a group', done => { const groupId = '123456'; @@ -535,14 +553,15 @@ describe('Api', () => { }); describe('issueTemplate', () => { + 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, + )}`; + 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(httpStatus.OK, 'test'); Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => { @@ -550,6 +569,49 @@ describe('Api', () => { done(); }); }); + + describe('when an error occurs while fetching an issue template', () => { + it('rejects the Promise', () => { + mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + + Api.issueTemplate(namespace, project, templateKey, templateType, () => { + expect(mock.history.get).toHaveLength(1); + }); + }); + }); + }); + + describe('issueTemplates', () => { + const namespace = 'some namespace'; + const project = 'some project'; + const templateType = 'template type'; + const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}`; + + it('fetches all templates by type', done => { + const expectedData = [ + { key: 'Template1', name: 'Template 1', content: 'This is template 1!' }, + ]; + mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); + + Api.issueTemplates(namespace, project, templateType, (error, response) => { + expect(response.length).toBe(1); + const { key, name, content } = response[0]; + expect(key).toBe('Template1'); + expect(name).toBe('Template 1'); + expect(content).toBe('This is template 1!'); + done(); + }); + }); + + describe('when an error occurs while fetching issue templates', () => { + it('rejects the Promise', () => { + mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + + Api.issueTemplates(namespace, project, templateType, () => { + expect(mock.history.get).toHaveLength(1); + }); + }); + }); }); describe('projectTemplates', () => { @@ -692,24 +754,23 @@ describe('Api', () => { }); describe('pipelineJobs', () => { - it('fetches the jobs for a given pipeline', done => { - const projectId = 123; - const pipelineId = 456; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`; - const payload = [ - { - name: 'test', - }, - ]; - mock.onGet(expectedUrl).reply(httpStatus.OK, payload); + it.each([undefined, {}, { foo: true }])( + 'fetches the jobs for a given pipeline given %p params', + async params => { + const projectId = 123; + const pipelineId = 456; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`; + const payload = [ + { + name: 'test', + }, + ]; + mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, payload); - Api.pipelineJobs(projectId, pipelineId) - .then(({ data }) => { - expect(data).toEqual(payload); - }) - .then(done) - .catch(done.fail); - }); + const { data } = await Api.pipelineJobs(projectId, pipelineId, params); + expect(data).toEqual(payload); + }, + ); }); describe('createBranch', () => { @@ -1232,4 +1293,91 @@ describe('Api', () => { }); }); }); + + describe('Feature Flag User List', () => { + let expectedUrl; + let projectId; + let mockUserList; + + beforeEach(() => { + projectId = 1000; + expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/feature_flags_user_lists`; + mockUserList = { + name: 'mock_user_list', + user_xids: '1,2,3,4', + project_id: 1, + id: 1, + iid: 1, + }; + }); + + describe('fetchFeatureFlagUserLists', () => { + it('GETs the right url', () => { + mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []); + + return Api.fetchFeatureFlagUserLists(projectId).then(({ data }) => { + expect(data).toEqual([]); + }); + }); + }); + + describe('searchFeatureFlagUserLists', () => { + it('GETs the right url', () => { + mock.onGet(expectedUrl, { params: { search: 'test' } }).replyOnce(httpStatus.OK, []); + + return Api.searchFeatureFlagUserLists(projectId, 'test').then(({ data }) => { + expect(data).toEqual([]); + }); + }); + }); + + describe('createFeatureFlagUserList', () => { + it('POSTs data to the right url', () => { + const mockUserListData = { + name: 'mock_user_list', + user_xids: '1,2,3,4', + }; + mock.onPost(expectedUrl, mockUserListData).replyOnce(httpStatus.OK, mockUserList); + + return Api.createFeatureFlagUserList(projectId, mockUserListData).then(({ data }) => { + expect(data).toEqual(mockUserList); + }); + }); + }); + + describe('fetchFeatureFlagUserList', () => { + it('GETs the right url', () => { + mock.onGet(`${expectedUrl}/1`).replyOnce(httpStatus.OK, mockUserList); + + return Api.fetchFeatureFlagUserList(projectId, 1).then(({ data }) => { + expect(data).toEqual(mockUserList); + }); + }); + }); + + describe('updateFeatureFlagUserList', () => { + it('PUTs the right url', () => { + mock + .onPut(`${expectedUrl}/1`) + .replyOnce(httpStatus.OK, { ...mockUserList, user_xids: '5' }); + + return Api.updateFeatureFlagUserList(projectId, { + ...mockUserList, + user_xids: '5', + }).then(({ data }) => { + expect(data).toEqual({ ...mockUserList, user_xids: '5' }); + }); + }); + }); + + describe('deleteFeatureFlagUserList', () => { + it('DELETEs the right url', () => { + mock.onDelete(`${expectedUrl}/1`).replyOnce(httpStatus.OK, 'deleted'); + + return Api.deleteFeatureFlagUserList(projectId, 1).then(({ data }) => { + expect(data).toBe('deleted'); + }); + }); + }); + }); }); diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index 7fd6a9e7b87..c6a9c911ccf 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -169,29 +169,6 @@ describe('AwardsHandler', () => { }); }); - describe('::userAuthored', () => { - it('should update tooltip to user authored title', () => { - const $votesBlock = $('.js-awards-block').eq(0); - const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); - $thumbsUpEmoji.attr('data-title', 'sam'); - awardsHandler.userAuthored($thumbsUpEmoji); - - expect($thumbsUpEmoji.data('originalTitle')).toBe( - 'You cannot vote on your own issue, MR and note', - ); - }); - - it('should restore tooltip back to initial vote list', () => { - const $votesBlock = $('.js-awards-block').eq(0); - const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); - $thumbsUpEmoji.attr('data-title', 'sam'); - awardsHandler.userAuthored($thumbsUpEmoji); - jest.advanceTimersByTime(2801); - - expect($thumbsUpEmoji.data('originalTitle')).toBe('sam'); - }); - }); - describe('::getAwardUrl', () => { it('returns the url for request', () => { expect(awardsHandler.getAwardUrl()).toBe('http://test.host/-/snippets/1/toggle_award_emoji'); diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js index baedbf5771a..77dcc28dd48 100644 --- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js +++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js @@ -1,20 +1,19 @@ import $ from 'jquery'; -import 'mousetrap'; +import Mousetrap from 'mousetrap'; import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { getSelectedFragment } from '~/lib/utils/common_utils'; -const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form'; - jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), getSelectedFragment: jest.fn().mockName('getSelectedFragment'), })); describe('ShortcutsIssuable', () => { - const fixtureName = 'snippets/show.html'; + const snippetShowFixtureName = 'snippets/show.html'; + const mrShowFixtureName = 'merge_requests/merge_request_of_current_user.html'; - preloadFixtures(fixtureName); + preloadFixtures(snippetShowFixtureName, mrShowFixtureName); beforeAll(done => { initCopyAsGFM(); @@ -27,25 +26,27 @@ describe('ShortcutsIssuable', () => { .catch(done.fail); }); - beforeEach(() => { - loadFixtures(fixtureName); - $('body').append( - `<div class="js-main-target-form"> - <textarea class="js-vue-comment-form"></textarea> - </div>`, - ); - document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); - - window.shortcut = new ShortcutsIssuable(true); - }); + describe('replyWithSelectedText', () => { + const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form'; + + beforeEach(() => { + loadFixtures(snippetShowFixtureName); + $('body').append( + `<div class="js-main-target-form"> + <textarea class="js-vue-comment-form"></textarea> + </div>`, + ); + document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); + + window.shortcut = new ShortcutsIssuable(true); + }); - afterEach(() => { - $(FORM_SELECTOR).remove(); + afterEach(() => { + $(FORM_SELECTOR).remove(); - delete window.shortcut; - }); + delete window.shortcut; + }); - describe('replyWithSelectedText', () => { // Stub getSelectedFragment to return a node with the provided HTML. const stubSelection = (html, invalidNode) => { getSelectedFragment.mockImplementation(() => { @@ -319,4 +320,55 @@ describe('ShortcutsIssuable', () => { }); }); }); + + describe('copyBranchName', () => { + let sidebarCollapsedBtn; + let sidebarExpandedBtn; + + beforeEach(() => { + loadFixtures(mrShowFixtureName); + + window.shortcut = new ShortcutsIssuable(); + + [sidebarCollapsedBtn, sidebarExpandedBtn] = document.querySelectorAll( + '.sidebar-source-branch button', + ); + + [sidebarCollapsedBtn, sidebarExpandedBtn].forEach(btn => jest.spyOn(btn, 'click')); + }); + + afterEach(() => { + delete window.shortcut; + }); + + describe('when the sidebar is expanded', () => { + beforeEach(() => { + // simulate the applied CSS styles when the + // sidebar is expanded + sidebarCollapsedBtn.style.display = 'none'; + + Mousetrap.trigger('b'); + }); + + it('clicks the "expanded" version of the copy source branch button', () => { + expect(sidebarExpandedBtn.click).toHaveBeenCalled(); + expect(sidebarCollapsedBtn.click).not.toHaveBeenCalled(); + }); + }); + + describe('when the sidebar is collapsed', () => { + beforeEach(() => { + // simulate the applied CSS styles when the + // sidebar is collapsed + sidebarExpandedBtn.style.display = 'none'; + + Mousetrap.trigger('b'); + }); + + it('clicks the "collapsed" version of the copy source branch button', () => { + expect(sidebarCollapsedBtn.click).toHaveBeenCalled(); + expect(sidebarExpandedBtn.click).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js index 590e36b16af..e2c73a5d5d9 100644 --- a/spec/frontend/blob/components/blob_header_default_actions_spec.js +++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js @@ -14,8 +14,13 @@ describe('Blob Header Default Actions', () => { let btnGroup; let buttons; + const blobHash = 'foo-bar'; + function createComponent(propsData = {}) { wrapper = mount(BlobHeaderActions, { + provide: { + blobHash, + }, propsData: { rawPath: Blob.rawPath, ...propsData, diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js index 43057353051..067a4ae61a0 100644 --- a/spec/frontend/blob/components/blob_header_filepath_spec.js +++ b/spec/frontend/blob/components/blob_header_filepath_spec.js @@ -65,7 +65,7 @@ describe('Blob Header Filepath', () => { {}, { scopedSlots: { - filepathPrepend: `<span>${slotContent}</span>`, + 'filepath-prepend': `<span>${slotContent}</span>`, }, }, ); diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js index 01d4bf834d2..3e84347bee4 100644 --- a/spec/frontend/blob/components/blob_header_spec.js +++ b/spec/frontend/blob/components/blob_header_spec.js @@ -11,7 +11,11 @@ describe('Blob Header Default Actions', () => { function createComponent(blobProps = {}, options = {}, propsData = {}, shouldMount = false) { const method = shouldMount ? mount : shallowMount; + const blobHash = 'foo-bar'; wrapper = method.call(this, BlobHeader, { + provide: { + blobHash, + }, propsData: { blob: { ...Blob, ...blobProps }, ...propsData, diff --git a/spec/frontend/blob/pipeline_tour_success_mock_data.js b/spec/frontend/blob/pipeline_tour_success_mock_data.js index 9dea3969d63..dbcba469df5 100644 --- a/spec/frontend/blob/pipeline_tour_success_mock_data.js +++ b/spec/frontend/blob/pipeline_tour_success_mock_data.js @@ -3,6 +3,8 @@ const modalProps = { projectMergeRequestsPath: 'some_mr_path', commitCookie: 'some_cookie', humanAccess: 'maintainer', + exampleLink: '/example', + codeQualityLink: '/code-quality-link', }; export default modalProps; diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js index a02c968c4b5..e8011558765 100644 --- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js +++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js @@ -75,7 +75,7 @@ describe('PipelineTourSuccessModal', () => { }); it('renders the link for codeQualityLink', () => { - expect(wrapper.find(GlLink).attributes('href')).toBe(wrapper.vm.$options.codeQualityLink); + expect(wrapper.find(GlLink).attributes('href')).toBe('/code-quality-link'); }); it('calls to remove cookie', () => { diff --git a/spec/frontend/boards/board_list_new_spec.js b/spec/frontend/boards/board_list_new_spec.js index 163611c2197..55516e3fd56 100644 --- a/spec/frontend/boards/board_list_new_spec.js +++ b/spec/frontend/boards/board_list_new_spec.js @@ -77,6 +77,8 @@ const createComponent = ({ provide: { groupId: null, rootPath: '/', + weightFeatureAvailable: false, + boardWeight: null, }, }); diff --git a/spec/frontend/boards/components/board_assignee_dropdown_spec.js b/spec/frontend/boards/components/board_assignee_dropdown_spec.js new file mode 100644 index 00000000000..e185a6d5419 --- /dev/null +++ b/spec/frontend/boards/components/board_assignee_dropdown_spec.js @@ -0,0 +1,308 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import { GlDropdownItem, GlAvatarLink, GlAvatarLabeled, GlSearchBoxByType } from '@gitlab/ui'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import VueApollo from 'vue-apollo'; +import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue'; +import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; +import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import store from '~/boards/stores'; +import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql'; +import searchUsers from '~/boards/queries/users_search.query.graphql'; +import { participants } from '../mock_data'; + +const localVue = createLocalVue(); + +localVue.use(VueApollo); + +describe('BoardCardAssigneeDropdown', () => { + let wrapper; + let fakeApollo; + let getIssueParticipantsSpy; + let getSearchUsersSpy; + + const iid = '111'; + const activeIssueName = 'test'; + const anotherIssueName = 'hello'; + + const createComponent = (search = '') => { + wrapper = mount(BoardAssigneeDropdown, { + data() { + return { + search, + selected: store.getters.activeIssue.assignees, + participants, + }; + }, + store, + provide: { + canUpdate: true, + rootPath: '', + }, + }); + }; + + const createComponentWithApollo = (search = '') => { + fakeApollo = createMockApollo([ + [getIssueParticipants, getIssueParticipantsSpy], + [searchUsers, getSearchUsersSpy], + ]); + + wrapper = mount(BoardAssigneeDropdown, { + localVue, + apolloProvider: fakeApollo, + data() { + return { + search, + selected: store.getters.activeIssue.assignees, + participants, + }; + }, + store, + provide: { + canUpdate: true, + rootPath: '', + }, + }); + }; + + const unassign = async () => { + wrapper.find('[data-testid="unassign"]').trigger('click'); + + await wrapper.vm.$nextTick(); + }; + + const openDropdown = async () => { + wrapper.find('[data-testid="edit-button"]').trigger('click'); + + await wrapper.vm.$nextTick(); + }; + + const findByText = text => { + return wrapper.findAll(GlDropdownItem).wrappers.find(node => node.text().indexOf(text) === 0); + }; + + beforeEach(() => { + store.state.activeId = '1'; + store.state.issues = { + '1': { + iid, + assignees: [{ username: activeIssueName, name: activeIssueName, id: activeIssueName }], + }, + }; + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + text + ${anotherIssueName} + ${activeIssueName} + `('finds item with $text', ({ text }) => { + const item = findByText(text); + + expect(item.exists()).toBe(true); + }); + + it('renders gl-avatar-link in gl-dropdown-item', () => { + const item = findByText('hello'); + + expect(item.find(GlAvatarLink).exists()).toBe(true); + }); + + it('renders gl-avatar-labeled in gl-avatar-link', () => { + const item = findByText('hello'); + + expect( + item + .find(GlAvatarLink) + .find(GlAvatarLabeled) + .exists(), + ).toBe(true); + }); + }); + + describe('when selected users are present', () => { + it('renders a divider', () => { + createComponent(); + + expect(wrapper.find('[data-testid="selected-user-divider"]').exists()).toBe(true); + }); + }); + + describe('when collapsed', () => { + it('renders IssuableAssignees', () => { + createComponent(); + + expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true); + expect(wrapper.find(MultiSelectDropdown).isVisible()).toBe(false); + }); + }); + + describe('when dropdown is open', () => { + beforeEach(async () => { + createComponent(); + + await openDropdown(); + }); + + it('shows assignees dropdown', async () => { + expect(wrapper.find(IssuableAssignees).isVisible()).toBe(false); + expect(wrapper.find(MultiSelectDropdown).isVisible()).toBe(true); + }); + + it('shows the issue returned as the activeIssue', async () => { + expect(findByText(activeIssueName).props('isChecked')).toBe(true); + }); + + describe('when "Unassign" is clicked', () => { + it('unassigns assignees', async () => { + await unassign(); + + expect(findByText('Unassign').props('isChecked')).toBe(true); + }); + }); + + describe('when an unselected item is clicked', () => { + beforeEach(async () => { + await unassign(); + }); + + it('assigns assignee in the dropdown', async () => { + wrapper.find('[data-testid="item_test"]').trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findByText(activeIssueName).props('isChecked')).toBe(true); + }); + + it('calls setAssignees with username list', async () => { + wrapper.find('[data-testid="item_test"]').trigger('click'); + + await wrapper.vm.$nextTick(); + + document.body.click(); + + await wrapper.vm.$nextTick(); + + expect(store.dispatch).toHaveBeenCalledWith('setAssignees', [activeIssueName]); + }); + }); + + describe('when the user off clicks', () => { + beforeEach(async () => { + await unassign(); + + document.body.click(); + + await wrapper.vm.$nextTick(); + }); + + it('calls setAssignees with username list', async () => { + expect(store.dispatch).toHaveBeenCalledWith('setAssignees', []); + }); + + it('closes the dropdown', async () => { + expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true); + }); + }); + }); + + it('renders divider after unassign', () => { + createComponent(); + + expect(wrapper.find('[data-testid="unassign-divider"]').exists()).toBe(true); + }); + + it.each` + assignees | expected + ${[{ id: 5, username: '', name: '' }]} | ${'Assignee'} + ${[{ id: 6, username: '', name: '' }, { id: 7, username: '', name: '' }]} | ${'2 Assignees'} + `( + 'when assignees have a length of $assignees.length, it renders $expected', + ({ assignees, expected }) => { + store.state.issues['1'].assignees = assignees; + + createComponent(); + + expect(wrapper.find(BoardEditableItem).props('title')).toBe(expected); + }, + ); + + describe('Apollo', () => { + beforeEach(() => { + getIssueParticipantsSpy = jest.fn().mockResolvedValue({ + data: { + issue: { + participants: { + nodes: [ + { + username: 'participant', + name: 'participant', + webUrl: '', + avatarUrl: '', + id: '', + }, + ], + }, + }, + }, + }); + getSearchUsersSpy = jest.fn().mockResolvedValue({ + data: { + users: { + nodes: [{ username: 'root', name: 'root', webUrl: '', avatarUrl: '', id: '' }], + }, + }, + }); + }); + + describe('when search is empty', () => { + beforeEach(() => { + createComponentWithApollo(); + }); + + it('calls getIssueParticipants', async () => { + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + expect(getIssueParticipantsSpy).toHaveBeenCalledWith({ id: 'gid://gitlab/Issue/111' }); + }); + }); + + describe('when search is not empty', () => { + beforeEach(() => { + createComponentWithApollo('search term'); + }); + + it('calls searchUsers', async () => { + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + expect(getSearchUsersSpy).toHaveBeenCalledWith({ search: 'search term' }); + }); + }); + }); + + it('finds GlSearchBoxByType', async () => { + createComponent(); + + await openDropdown(); + + expect(wrapper.find(GlSearchBoxByType).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index a3ddcdf01b7..5e23c781eae 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -175,7 +175,7 @@ describe('BoardCard', () => { wrapper.trigger('mousedown'); wrapper.trigger('mouseup'); - expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, undefined); + expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false); expect(boardsStore.detail.list).toEqual(wrapper.vm.list); }); @@ -188,7 +188,7 @@ describe('BoardCard', () => { wrapper.trigger('mousedown'); wrapper.trigger('mouseup'); - expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', undefined); + expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false); }); }); diff --git a/spec/frontend/boards/components/board_column_new_spec.js b/spec/frontend/boards/components/board_column_new_spec.js new file mode 100644 index 00000000000..4aafc3a867a --- /dev/null +++ b/spec/frontend/boards/components/board_column_new_spec.js @@ -0,0 +1,72 @@ +import { shallowMount } from '@vue/test-utils'; + +import { listObj } from 'jest/boards/mock_data'; +import BoardColumn from '~/boards/components/board_column_new.vue'; +import List from '~/boards/models/list'; +import { ListType } from '~/boards/constants'; +import { createStore } from '~/boards/stores'; + +describe('Board Column Component', () => { + let wrapper; + let store; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => { + const boardId = '1'; + + const listMock = { + ...listObj, + list_type: listType, + collapsed, + }; + + if (listType === ListType.assignee) { + delete listMock.label; + listMock.user = {}; + } + + const list = new List({ ...listMock, doNotFetchIssues: true }); + + store = createStore(); + + wrapper = shallowMount(BoardColumn, { + store, + propsData: { + disabled: false, + list, + }, + provide: { + boardId, + }, + }); + }; + + const isExpandable = () => wrapper.classes('is-expandable'); + const isCollapsed = () => wrapper.classes('is-collapsed'); + + describe('Given different list types', () => { + it('is expandable when List Type is `backlog`', () => { + createComponent({ listType: ListType.backlog }); + + expect(isExpandable()).toBe(true); + }); + }); + + describe('expanded / collapsed column', () => { + it('has class is-collapsed when list is collapsed', () => { + createComponent({ collapsed: false }); + + expect(wrapper.vm.list.isExpanded).toBe(true); + }); + + it('does not have class is-collapsed when list is expanded', () => { + createComponent({ collapsed: true }); + + expect(isCollapsed()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index 2a4dbbb989e..ba11225676b 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -78,7 +78,7 @@ describe('Board Column Component', () => { }); }); - describe('expanded / collaped column', () => { + describe('expanded / collapsed column', () => { it('has class is-collapsed when list is collapsed', () => { createComponent({ collapsed: false }); diff --git a/spec/frontend/boards/components/board_list_header_new_spec.js b/spec/frontend/boards/components/board_list_header_new_spec.js new file mode 100644 index 00000000000..80786d82620 --- /dev/null +++ b/spec/frontend/boards/components/board_list_header_new_spec.js @@ -0,0 +1,169 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; + +import { listObj } from 'jest/boards/mock_data'; +import BoardListHeader from '~/boards/components/board_list_header_new.vue'; +import List from '~/boards/models/list'; +import { ListType } from '~/boards/constants'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('Board List Header Component', () => { + let wrapper; + let store; + + const updateListSpy = jest.fn(); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + localStorage.clear(); + }); + + const createComponent = ({ + listType = ListType.backlog, + collapsed = false, + withLocalStorage = true, + currentUserId = null, + } = {}) => { + const boardId = '1'; + + const listMock = { + ...listObj, + list_type: listType, + collapsed, + }; + + if (listType === ListType.assignee) { + delete listMock.label; + listMock.user = {}; + } + + const list = new List({ ...listMock, doNotFetchIssues: true }); + + if (withLocalStorage) { + localStorage.setItem( + `boards.${boardId}.${list.type}.${list.id}.expanded`, + (!collapsed).toString(), + ); + } + + store = new Vuex.Store({ + state: {}, + actions: { updateList: updateListSpy }, + getters: {}, + }); + + wrapper = shallowMount(BoardListHeader, { + store, + localVue, + propsData: { + disabled: false, + list, + }, + provide: { + boardId, + weightFeatureAvailable: false, + currentUserId, + }, + }); + }; + + const isExpanded = () => wrapper.vm.list.isExpanded; + const isCollapsed = () => !isExpanded(); + + const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); + const findCaret = () => wrapper.find('.board-title-caret'); + + describe('Add issue button', () => { + const hasNoAddButton = [ListType.promotion, ListType.blank, ListType.closed]; + const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee]; + + it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => { + createComponent({ listType }); + + expect(findAddIssueButton().exists()).toBe(false); + }); + + it.each(hasAddButton)('does render when List Type is `%s`', listType => { + createComponent({ listType }); + + expect(findAddIssueButton().exists()).toBe(true); + }); + + it('has a test for each list type', () => { + createComponent(); + + Object.values(ListType).forEach(value => { + expect([...hasAddButton, ...hasNoAddButton]).toContain(value); + }); + }); + + it('does render when logged out', () => { + createComponent(); + + expect(findAddIssueButton().exists()).toBe(true); + }); + }); + + describe('expanding / collapsing the column', () => { + it('does not collapse when clicking the header', async () => { + createComponent(); + + expect(isCollapsed()).toBe(false); + + wrapper.find('[data-testid="board-list-header"]').trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(isCollapsed()).toBe(false); + }); + + it('collapses expanded Column when clicking the collapse icon', async () => { + createComponent(); + + expect(isExpanded()).toBe(true); + + findCaret().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(isCollapsed()).toBe(true); + }); + + it('expands collapsed Column when clicking the expand icon', async () => { + createComponent({ collapsed: true }); + + expect(isCollapsed()).toBe(true); + + findCaret().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(isCollapsed()).toBe(false); + }); + + it("when logged in it calls list update and doesn't set localStorage", async () => { + createComponent({ withLocalStorage: false, currentUserId: 1 }); + + findCaret().vm.$emit('click'); + await wrapper.vm.$nextTick(); + + expect(updateListSpy).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); + }); + + it("when logged out it doesn't call list update and sets localStorage", async () => { + createComponent(); + + findCaret().vm.$emit('click'); + await wrapper.vm.$nextTick(); + + expect(updateListSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); + }); + }); +}); diff --git a/spec/frontend/boards/components/board_new_issue_new_spec.js b/spec/frontend/boards/components/board_new_issue_new_spec.js new file mode 100644 index 00000000000..af4bad65121 --- /dev/null +++ b/spec/frontend/boards/components/board_new_issue_new_spec.js @@ -0,0 +1,115 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import BoardNewIssue from '~/boards/components/board_new_issue_new.vue'; + +import '~/boards/models/list'; +import { mockListsWithModel } from '../mock_data'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('Issue boards new issue form', () => { + let wrapper; + let vm; + + const addListNewIssuesSpy = jest.fn(); + + const findSubmitButton = () => wrapper.find({ ref: 'submitButton' }); + const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); + const findSubmitForm = () => wrapper.find({ ref: 'submitForm' }); + + const submitIssue = () => { + const dummySubmitEvent = { + preventDefault() {}, + }; + + return findSubmitForm().trigger('submit', dummySubmitEvent); + }; + + beforeEach(() => { + const store = new Vuex.Store({ + state: {}, + actions: { addListNewIssue: addListNewIssuesSpy }, + getters: {}, + }); + + wrapper = shallowMount(BoardNewIssue, { + propsData: { + disabled: false, + list: mockListsWithModel[0], + }, + store, + localVue, + provide: { + groupId: null, + weightFeatureAvailable: false, + boardWeight: null, + }, + }); + + vm = wrapper.vm; + + return vm.$nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('calls submit if submit button is clicked', async () => { + jest.spyOn(wrapper.vm, 'submit').mockImplementation(); + wrapper.setData({ title: 'Testing Title' }); + + await vm.$nextTick(); + await submitIssue(); + expect(wrapper.vm.submit).toHaveBeenCalled(); + }); + + it('disables submit button if title is empty', () => { + expect(findSubmitButton().props().disabled).toBe(true); + }); + + it('enables submit button if title is not empty', async () => { + wrapper.setData({ title: 'Testing Title' }); + + await vm.$nextTick(); + expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title'); + expect(findSubmitButton().props().disabled).toBe(false); + }); + + it('clears title after clicking cancel', async () => { + findCancelButton().trigger('click'); + + await vm.$nextTick(); + expect(vm.title).toBe(''); + }); + + describe('submit success', () => { + it('creates new issue', async () => { + wrapper.setData({ title: 'submit issue' }); + + await vm.$nextTick(); + await submitIssue(); + expect(addListNewIssuesSpy).toHaveBeenCalled(); + }); + + it('enables button after submit', async () => { + jest.spyOn(wrapper.vm, 'submit').mockImplementation(); + wrapper.setData({ title: 'submit issue' }); + + await vm.$nextTick(); + await submitIssue(); + expect(findSubmitButton().props().disabled).toBe(false); + }); + + it('clears title after submit', async () => { + wrapper.setData({ title: 'submit issue' }); + + await vm.$nextTick(); + await submitIssue(); + await vm.$nextTick(); + expect(vm.title).toBe(''); + }); + }); +}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js new file mode 100644 index 00000000000..b034c8cb11d --- /dev/null +++ b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js @@ -0,0 +1,137 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDatepicker } from '@gitlab/ui'; +import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import { createStore } from '~/boards/stores'; +import createFlash from '~/flash'; + +const TEST_DUE_DATE = '2020-02-20'; +const TEST_FORMATTED_DUE_DATE = 'Feb 20, 2020'; +const TEST_PARSED_DATE = new Date(2020, 1, 20); +const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, dueDate: null, referencePath: 'h/b#2' }; + +jest.mock('~/flash'); + +describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => { + let wrapper; + let store; + + afterEach(() => { + wrapper.destroy(); + store = null; + wrapper = null; + }); + + const createWrapper = ({ dueDate = null } = {}) => { + store = createStore(); + store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } }; + store.state.activeId = TEST_ISSUE.id; + + wrapper = shallowMount(BoardSidebarDueDate, { + store, + provide: { + canUpdate: true, + }, + stubs: { + 'board-editable-item': BoardEditableItem, + }, + }); + }; + + const findDatePicker = () => wrapper.find(GlDatepicker); + const findResetButton = () => wrapper.find('[data-testid="reset-button"]'); + const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); + + it('renders "None" when no due date is set', () => { + createWrapper(); + + expect(findCollapsed().text()).toBe('None'); + expect(findResetButton().exists()).toBe(false); + }); + + it('renders formatted due date with reset button when set', () => { + createWrapper({ dueDate: TEST_DUE_DATE }); + + expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE); + expect(findResetButton().exists()).toBe(true); + }); + + describe('when due date is submitted', () => { + beforeEach(async () => { + createWrapper(); + + jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { + store.state.issues[TEST_ISSUE.id].dueDate = TEST_DUE_DATE; + }); + findDatePicker().vm.$emit('input', TEST_PARSED_DATE); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders formatted due date with reset button', () => { + expect(findCollapsed().isVisible()).toBe(true); + expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE); + expect(findResetButton().exists()).toBe(true); + }); + + it('commits change to the server', () => { + expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalledWith({ + dueDate: TEST_DUE_DATE, + projectPath: 'h/b', + }); + }); + }); + + describe('when due date is cleared', () => { + beforeEach(async () => { + createWrapper(); + + jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { + store.state.issues[TEST_ISSUE.id].dueDate = null; + }); + findDatePicker().vm.$emit('clear'); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders "None"', () => { + expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled(); + expect(findCollapsed().isVisible()).toBe(true); + expect(findCollapsed().text()).toBe('None'); + }); + }); + + describe('when due date is resetted', () => { + beforeEach(async () => { + createWrapper({ dueDate: TEST_DUE_DATE }); + + jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { + store.state.issues[TEST_ISSUE.id].dueDate = null; + }); + findResetButton().vm.$emit('click'); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders "None"', () => { + expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled(); + expect(findCollapsed().isVisible()).toBe(true); + expect(findCollapsed().text()).toBe('None'); + }); + }); + + describe('when the mutation fails', () => { + beforeEach(async () => { + createWrapper({ dueDate: TEST_DUE_DATE }); + + jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => { + throw new Error(['failed mutation']); + }); + findDatePicker().vm.$emit('input', 'Invalid date'); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders former issue due date', () => { + expect(findCollapsed().isVisible()).toBe(true); + expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE); + expect(createFlash).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js new file mode 100644 index 00000000000..ee54c662167 --- /dev/null +++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js @@ -0,0 +1,157 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; +import * as types from '~/boards/stores/mutation_types'; +import { createStore } from '~/boards/stores'; +import { mockActiveIssue } from '../../mock_data'; +import createFlash from '~/flash'; + +jest.mock('~/flash.js'); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => { + let wrapper; + let store; + + const findNotificationHeader = () => wrapper.find("[data-testid='notification-header-text']"); + const findToggle = () => wrapper.find(GlToggle); + const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const createComponent = (activeIssue = { ...mockActiveIssue }) => { + store = createStore(); + store.state.issues = { [activeIssue.id]: activeIssue }; + store.state.activeId = activeIssue.id; + + wrapper = mount(BoardSidebarSubscription, { + localVue, + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + store = null; + jest.clearAllMocks(); + }); + + describe('Board sidebar subscription component template', () => { + it('displays "notifications" heading', () => { + createComponent(); + + expect(findNotificationHeader().text()).toBe('Notifications'); + }); + + it('renders toggle as "off" when currently not subscribed', () => { + createComponent(); + + expect(findToggle().exists()).toBe(true); + expect(findToggle().props('value')).toBe(false); + }); + + it('renders toggle as "on" when currently subscribed', () => { + createComponent({ + ...mockActiveIssue, + subscribed: true, + }); + + expect(findToggle().exists()).toBe(true); + expect(findToggle().props('value')).toBe(true); + }); + + describe('when notification emails have been disabled', () => { + beforeEach(() => { + createComponent({ + ...mockActiveIssue, + emailsDisabled: true, + }); + }); + + it('displays a message that notification have been disabled', () => { + expect(findNotificationHeader().text()).toBe( + 'Notifications have been disabled by the project or group owner', + ); + }); + + it('does not render the toggle button', () => { + expect(findToggle().exists()).toBe(false); + }); + }); + }); + + describe('Board sidebar subscription component `behavior`', () => { + const mockSetActiveIssueSubscribed = subscribedState => { + jest.spyOn(wrapper.vm, 'setActiveIssueSubscribed').mockImplementation(async () => { + store.commit(types.UPDATE_ISSUE_BY_ID, { + issueId: mockActiveIssue.id, + prop: 'subscribed', + value: subscribedState, + }); + }); + }; + + it('subscribing to notification', async () => { + createComponent(); + mockSetActiveIssueSubscribed(true); + + expect(findGlLoadingIcon().exists()).toBe(false); + + findToggle().trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findGlLoadingIcon().exists()).toBe(true); + expect(wrapper.vm.setActiveIssueSubscribed).toHaveBeenCalledWith({ + subscribed: true, + projectPath: 'gitlab-org/test-subgroup/gitlab-test', + }); + + await wrapper.vm.$nextTick(); + + expect(findGlLoadingIcon().exists()).toBe(false); + expect(findToggle().props('value')).toBe(true); + }); + + it('unsubscribing from notification', async () => { + createComponent({ + ...mockActiveIssue, + subscribed: true, + }); + mockSetActiveIssueSubscribed(false); + + expect(findGlLoadingIcon().exists()).toBe(false); + + findToggle().trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.setActiveIssueSubscribed).toHaveBeenCalledWith({ + subscribed: false, + projectPath: 'gitlab-org/test-subgroup/gitlab-test', + }); + expect(findGlLoadingIcon().exists()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect(findGlLoadingIcon().exists()).toBe(false); + expect(findToggle().props('value')).toBe(false); + }); + + it('flashes an error message when setting the subscribed state fails', async () => { + createComponent(); + jest.spyOn(wrapper.vm, 'setActiveIssueSubscribed').mockImplementation(async () => { + throw new Error(); + }); + + findToggle().trigger('click'); + + await wrapper.vm.$nextTick(); + expect(createFlash).toHaveBeenNthCalledWith(1, { + message: wrapper.vm.$options.i18n.updateSubscribedErrorMessage, + }); + }); + }); +}); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 50c0a85fc70..58f67231d55 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -2,6 +2,7 @@ /* global List */ import Vue from 'vue'; +import { keyBy } from 'lodash'; import '~/boards/models/list'; import '~/boards/models/issue'; import boardsStore from '~/boards/stores/boards_store'; @@ -175,6 +176,14 @@ export const mockIssue = { }, }; +export const mockActiveIssue = { + ...mockIssue, + id: 436, + iid: '27', + subscribed: false, + emailsDisabled: false, +}; + export const mockIssueWithModel = new ListIssue(mockIssue); export const mockIssue2 = { @@ -290,6 +299,7 @@ export const mockLists = [ assignee: null, milestone: null, loading: false, + issuesSize: 1, }, { id: 'gid://gitlab/List/2', @@ -307,9 +317,12 @@ export const mockLists = [ assignee: null, milestone: null, loading: false, + issuesSize: 0, }, ]; +export const mockListsById = keyBy(mockLists, 'id'); + export const mockListsWithModel = mockLists.map(listMock => Vue.observable(new List({ ...listMock, doNotFetchIssues: true })), ); @@ -319,6 +332,23 @@ export const mockIssuesByListId = { 'gid://gitlab/List/2': mockIssues.map(({ id }) => id), }; +export const participants = [ + { + id: '1', + username: 'test', + name: 'test', + avatar: '', + avatarUrl: '', + }, + { + id: '2', + username: 'hello', + name: 'hello', + avatar: '', + avatarUrl: '', + }, +]; + export const issues = { [mockIssue.id]: mockIssue, [mockIssue2.id]: mockIssue2, diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 78e70161121..4d529580a7a 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -2,17 +2,21 @@ import testAction from 'helpers/vuex_action_helper'; import { mockListsWithModel, mockLists, + mockListsById, mockIssue, mockIssueWithModel, mockIssue2WithModel, rawIssue, mockIssues, labels, + mockActiveIssue, } from '../mock_data'; import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; -import { inactiveId, ListType } from '~/boards/constants'; +import { inactiveId } from '~/boards/constants'; import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql'; +import destroyBoardListMutation from '~/boards/queries/board_list_destroy.mutation.graphql'; +import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util'; const expectNotImplemented = action => { @@ -116,7 +120,7 @@ describe('fetchLists', () => { payload: formattedLists, }, ], - [{ type: 'showWelcomeList' }], + [{ type: 'generateDefaultLists' }], done, ); }); @@ -146,14 +150,15 @@ describe('fetchLists', () => { payload: formattedLists, }, ], - [{ type: 'createList', payload: { backlog: true } }, { type: 'showWelcomeList' }], + [{ type: 'createList', payload: { backlog: true } }, { type: 'generateDefaultLists' }], done, ); }); }); -describe('showWelcomeList', () => { - it('should dispatch addList action', done => { +describe('generateDefaultLists', () => { + let store; + beforeEach(() => { const state = { endpoints: { fullPath: 'gitlab-org', boardId: '1' }, boardType: 'group', @@ -161,26 +166,19 @@ describe('showWelcomeList', () => { boardLists: [{ type: 'backlog' }, { type: 'closed' }], }; - const blankList = { - id: 'blank', - listType: ListType.blank, - title: 'Welcome to your issue board!', - position: 0, - }; - - testAction( - actions.showWelcomeList, - {}, + store = { + commit: jest.fn(), + dispatch: jest.fn(() => Promise.resolve()), state, - [], - [{ type: 'addList', payload: blankList }], - done, - ); + }; }); -}); -describe('generateDefaultLists', () => { - expectNotImplemented(actions.generateDefaultLists); + it('should dispatch fetchLabels', () => { + return actions.generateDefaultLists(store).then(() => { + expect(store.dispatch.mock.calls[0]).toEqual(['fetchLabels', 'to do']); + expect(store.dispatch.mock.calls[1]).toEqual(['fetchLabels', 'doing']); + }); + }); }); describe('createList', () => { @@ -323,8 +321,82 @@ describe('updateList', () => { }); }); -describe('deleteList', () => { - expectNotImplemented(actions.deleteList); +describe('removeList', () => { + let state; + const list = mockLists[0]; + const listId = list.id; + const mutationVariables = { + mutation: destroyBoardListMutation, + variables: { + listId, + }, + }; + + beforeEach(() => { + state = { + boardLists: mockListsById, + }; + }); + + afterEach(() => { + state = null; + }); + + it('optimistically deletes the list', () => { + const commit = jest.fn(); + + actions.removeList({ commit, state }, listId); + + expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]); + }); + + it('keeps the updated list if remove succeeds', async () => { + const commit = jest.fn(); + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + destroyBoardList: { + errors: [], + }, + }, + }); + + await actions.removeList({ commit, state }, listId); + + expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); + expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]); + }); + + it('restores the list if update fails', async () => { + const commit = jest.fn(); + jest.spyOn(gqlClient, 'mutate').mockResolvedValue(Promise.reject()); + + await actions.removeList({ commit, state }, listId); + + expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); + expect(commit.mock.calls).toEqual([ + [types.REMOVE_LIST, listId], + [types.REMOVE_LIST_FAILURE, mockListsById], + ]); + }); + + it('restores the list if update response has errors', async () => { + const commit = jest.fn(); + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + destroyBoardList: { + errors: ['update failed, ID invalid'], + }, + }, + }); + + await actions.removeList({ commit, state }, listId); + + expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); + expect(commit.mock.calls).toEqual([ + [types.REMOVE_LIST, listId], + [types.REMOVE_LIST_FAILURE, mockListsById], + ]); + }); }); describe('fetchIssuesForList', () => { @@ -560,41 +632,106 @@ describe('moveIssue', () => { }); }); -describe('createNewIssue', () => { - expectNotImplemented(actions.createNewIssue); +describe('setAssignees', () => { + const node = { username: 'name' }; + const name = 'username'; + const projectPath = 'h/h'; + const refPath = `${projectPath}#3`; + const iid = '1'; + + beforeEach(() => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { issueSetAssignees: { issue: { assignees: { nodes: [{ ...node }] } } } }, + }); + }); + + it('calls mutate with the correct values', async () => { + await actions.setAssignees( + { commit: () => {}, getters: { activeIssue: { iid, referencePath: refPath } } }, + [name], + ); + + expect(gqlClient.mutate).toHaveBeenCalledWith({ + mutation: updateAssignees, + variables: { iid, assigneeUsernames: [name], projectPath }, + }); + }); + + it('calls the correct mutation with the correct values', done => { + testAction( + actions.setAssignees, + {}, + { activeIssue: { iid, referencePath: refPath }, commit: () => {} }, + [ + { + type: 'UPDATE_ISSUE_BY_ID', + payload: { prop: 'assignees', issueId: undefined, value: [node] }, + }, + ], + [], + done, + ); + }); }); -describe('addListIssue', () => { - it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => { - const payload = { - list: mockLists[0], - issue: mockIssue, - position: 0, - }; +describe('createNewIssue', () => { + const state = { + boardType: 'group', + endpoints: { + fullPath: 'gitlab-org/gitlab', + }, + }; + + it('should return issue from API on success', async () => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + createIssue: { + issue: mockIssue, + errors: [], + }, + }, + }); + + const result = await actions.createNewIssue({ state }, mockIssue); + expect(result).toEqual(mockIssue); + }); + + it('should commit CREATE_ISSUE_FAILURE mutation when API returns an error', done => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + createIssue: { + issue: {}, + errors: [{ foo: 'bar' }], + }, + }, + }); + + const payload = mockIssue; testAction( - actions.addListIssue, + actions.createNewIssue, payload, - {}, - [{ type: types.ADD_ISSUE_TO_LIST, payload }], + state, + [{ type: types.CREATE_ISSUE_FAILURE }], [], done, ); }); }); -describe('addListIssueFailure', () => { - it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => { +describe('addListIssue', () => { + it('should commit ADD_ISSUE_TO_LIST mutation', done => { const payload = { list: mockLists[0], issue: mockIssue, + position: 0, }; testAction( - actions.addListIssueFailure, + actions.addListIssue, payload, {}, - [{ type: types.ADD_ISSUE_TO_LIST_FAILURE, payload }], + [{ type: types.ADD_ISSUE_TO_LIST, payload }], [], done, ); @@ -603,7 +740,7 @@ describe('addListIssueFailure', () => { describe('setActiveIssueLabels', () => { const state = { issues: { [mockIssue.id]: mockIssue } }; - const getters = { getActiveIssue: mockIssue }; + const getters = { activeIssue: mockIssue }; const testLabelIds = labels.map(label => label.id); const input = { addLabelIds: testLabelIds, @@ -617,7 +754,7 @@ describe('setActiveIssueLabels', () => { .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); const payload = { - issueId: getters.getActiveIssue.id, + issueId: getters.activeIssue.id, prop: 'labels', value: labels, }; @@ -646,6 +783,108 @@ describe('setActiveIssueLabels', () => { }); }); +describe('setActiveIssueDueDate', () => { + const state = { issues: { [mockIssue.id]: mockIssue } }; + const getters = { activeIssue: mockIssue }; + const testDueDate = '2020-02-20'; + const input = { + dueDate: testDueDate, + projectPath: 'h/b', + }; + + it('should commit due date after setting the issue', done => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + updateIssue: { + issue: { + dueDate: testDueDate, + }, + errors: [], + }, + }, + }); + + const payload = { + issueId: getters.activeIssue.id, + prop: 'dueDate', + value: testDueDate, + }; + + testAction( + actions.setActiveIssueDueDate, + input, + { ...state, ...getters }, + [ + { + type: types.UPDATE_ISSUE_BY_ID, + payload, + }, + ], + [], + done, + ); + }); + + it('throws error if fails', async () => { + jest + .spyOn(gqlClient, 'mutate') + .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); + + await expect(actions.setActiveIssueDueDate({ getters }, input)).rejects.toThrow(Error); + }); +}); + +describe('setActiveIssueSubscribed', () => { + const state = { issues: { [mockActiveIssue.id]: mockActiveIssue } }; + const getters = { activeIssue: mockActiveIssue }; + const subscribedState = true; + const input = { + subscribedState, + projectPath: 'gitlab-org/gitlab-test', + }; + + it('should commit subscribed status', done => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + issueSetSubscription: { + issue: { + subscribed: subscribedState, + }, + errors: [], + }, + }, + }); + + const payload = { + issueId: getters.activeIssue.id, + prop: 'subscribed', + value: subscribedState, + }; + + testAction( + actions.setActiveIssueSubscribed, + input, + { ...state, ...getters }, + [ + { + type: types.UPDATE_ISSUE_BY_ID, + payload, + }, + ], + [], + done, + ); + }); + + it('throws error if fails', async () => { + jest + .spyOn(gqlClient, 'mutate') + .mockResolvedValue({ data: { issueSetSubscription: { errors: ['failed mutation'] } } }); + + await expect(actions.setActiveIssueSubscribed({ getters }, input)).rejects.toThrow(Error); + }); +}); + describe('fetchBacklog', () => { expectNotImplemented(actions.fetchBacklog); }); diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index b987080abab..64025726dd1 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -10,13 +10,13 @@ import { } from '../mock_data'; describe('Boards - Getters', () => { - describe('getLabelToggleState', () => { + describe('labelToggleState', () => { it('should return "on" when isShowingLabels is true', () => { const state = { isShowingLabels: true, }; - expect(getters.getLabelToggleState(state)).toBe('on'); + expect(getters.labelToggleState(state)).toBe('on'); }); it('should return "off" when isShowingLabels is false', () => { @@ -24,7 +24,7 @@ describe('Boards - Getters', () => { isShowingLabels: false, }; - expect(getters.getLabelToggleState(state)).toBe('off'); + expect(getters.labelToggleState(state)).toBe('off'); }); }); @@ -112,7 +112,7 @@ describe('Boards - Getters', () => { }); }); - describe('getActiveIssue', () => { + describe('activeIssue', () => { it.each` id | expected ${'1'} | ${'issue'} @@ -120,11 +120,27 @@ describe('Boards - Getters', () => { `('returns $expected when $id is passed to state', ({ id, expected }) => { const state = { issues: { '1': 'issue' }, activeId: id }; - expect(getters.getActiveIssue(state)).toEqual(expected); + expect(getters.activeIssue(state)).toEqual(expected); }); }); - describe('getIssues', () => { + describe('projectPathByIssueId', () => { + it('returns project path for the active issue', () => { + const mockActiveIssue = { + referencePath: 'gitlab-org/gitlab-test#1', + }; + expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual( + 'gitlab-org/gitlab-test', + ); + }); + + it('returns empty string as project when active issue is an empty object', () => { + const mockActiveIssue = {}; + expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual(''); + }); + }); + + describe('getIssuesByList', () => { const boardsState = { issuesByListId: mockIssuesByListId, issues, @@ -132,7 +148,7 @@ describe('Boards - Getters', () => { it('returns issues for a given listId', () => { const getIssueById = issueId => [mockIssue, mockIssue2].find(({ id }) => id === issueId); - expect(getters.getIssues(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual( + expect(getters.getIssuesByList(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual( mockIssues, ); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 6e53f184bb3..e1e57a8fd43 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -82,7 +82,7 @@ describe('Board Store Mutations', () => { mutations.SET_ACTIVE_ID(state, expected); }); - it('updates aciveListId to be the value that is passed', () => { + it('updates activeListId to be the value that is passed', () => { expect(state.activeId).toBe(expected.id); }); @@ -101,6 +101,34 @@ describe('Board Store Mutations', () => { }); }); + describe('CREATE_LIST_FAILURE', () => { + it('sets error message', () => { + mutations.CREATE_LIST_FAILURE(state); + + expect(state.error).toEqual('An error occurred while creating the list. Please try again.'); + }); + }); + + describe('RECEIVE_LABELS_FAILURE', () => { + it('sets error message', () => { + mutations.RECEIVE_LABELS_FAILURE(state); + + expect(state.error).toEqual( + 'An error occurred while fetching labels. Please reload the page.', + ); + }); + }); + + describe('GENERATE_DEFAULT_LISTS_FAILURE', () => { + it('sets error message', () => { + mutations.GENERATE_DEFAULT_LISTS_FAILURE(state); + + expect(state.error).toEqual( + 'An error occurred while generating lists. Please reload the page.', + ); + }); + }); + describe('REQUEST_ADD_LIST', () => { expectNotImplemented(mutations.REQUEST_ADD_LIST); }); @@ -156,16 +184,43 @@ describe('Board Store Mutations', () => { }); }); - describe('REQUEST_REMOVE_LIST', () => { - expectNotImplemented(mutations.REQUEST_REMOVE_LIST); - }); + describe('REMOVE_LIST', () => { + it('removes list from boardLists', () => { + const [list, secondList] = mockListsWithModel; + const expected = { + [secondList.id]: secondList, + }; + state = { + ...state, + boardLists: { ...initialBoardListsState }, + }; - describe('RECEIVE_REMOVE_LIST_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_SUCCESS); + mutations[types.REMOVE_LIST](state, list.id); + + expect(state.boardLists).toEqual(expected); + }); }); - describe('RECEIVE_REMOVE_LIST_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR); + describe('REMOVE_LIST_FAILURE', () => { + it('restores lists from backup', () => { + const backupLists = { ...initialBoardListsState }; + + mutations[types.REMOVE_LIST_FAILURE](state, backupLists); + + expect(state.boardLists).toEqual(backupLists); + }); + + it('sets error state', () => { + const backupLists = { ...initialBoardListsState }; + state = { + ...state, + error: undefined, + }; + + mutations[types.REMOVE_LIST_FAILURE](state, backupLists); + + expect(state.error).toEqual('An error occurred while removing the list. Please try again.'); + }); }); describe('RESET_ISSUES', () => { @@ -387,6 +442,14 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR); }); + describe('CREATE_ISSUE_FAILURE', () => { + it('sets error message on state', () => { + mutations.CREATE_ISSUE_FAILURE(state); + + expect(state.error).toBe('An error occurred while creating the issue. Please try again.'); + }); + }); + describe('ADD_ISSUE_TO_LIST', () => { it('adds issue to issues state and issue id in list in issuesByListId', () => { const listIssues = { @@ -400,17 +463,45 @@ describe('Board Store Mutations', () => { ...state, issuesByListId: listIssues, issues, + boardLists: initialBoardListsState, }; - mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 }); + expect(state.boardLists['gid://gitlab/List/1'].issuesSize).toBe(1); + + mutations.ADD_ISSUE_TO_LIST(state, { list: mockListsWithModel[0], issue: mockIssue2 }); expect(state.issuesByListId['gid://gitlab/List/1']).toContain(mockIssue2.id); expect(state.issues[mockIssue2.id]).toEqual(mockIssue2); + expect(state.boardLists['gid://gitlab/List/1'].issuesSize).toBe(2); }); }); describe('ADD_ISSUE_TO_LIST_FAILURE', () => { - it('removes issue id from list in issuesByListId', () => { + it('removes issue id from list in issuesByListId and sets error message', () => { + const listIssues = { + 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], + }; + const issues = { + '1': mockIssue, + '2': mockIssue2, + }; + + state = { + ...state, + issuesByListId: listIssues, + issues, + boardLists: initialBoardListsState, + }; + + mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id }); + + expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); + expect(state.error).toBe('An error occurred while creating the issue. Please try again.'); + }); + }); + + describe('REMOVE_ISSUE_FROM_LIST', () => { + it('removes issue id from list in issuesByListId and deletes issue from state', () => { const listIssues = { 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], }; @@ -426,9 +517,10 @@ describe('Board Store Mutations', () => { boardLists: initialBoardListsState, }; - mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 }); + mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id }); expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); + expect(state.issues).not.toContain(mockIssue2); }); }); diff --git a/spec/frontend/ci_lint/components/ci_lint_results_spec.js b/spec/frontend/ci_lint/components/ci_lint_results_spec.js index 37575a988c5..93c2d2dbcf3 100644 --- a/spec/frontend/ci_lint/components/ci_lint_results_spec.js +++ b/spec/frontend/ci_lint/components/ci_lint_results_spec.js @@ -1,20 +1,24 @@ import { shallowMount, mount } from '@vue/test-utils'; -import { GlTable } from '@gitlab/ui'; +import { GlTable, GlLink } from '@gitlab/ui'; import CiLintResults from '~/ci_lint/components/ci_lint_results.vue'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { mockJobs, mockErrors, mockWarnings } from '../mock_data'; describe('CI Lint Results', () => { let wrapper; + const defaultProps = { + valid: true, + jobs: mockJobs, + errors: [], + warnings: [], + dryRun: false, + lintHelpPagePath: '/help', + }; const createComponent = (props = {}, mountFn = shallowMount) => { wrapper = mountFn(CiLintResults, { propsData: { - valid: true, - jobs: mockJobs, - errors: [], - warnings: [], - dryRun: false, + ...defaultProps, ...props, }, }); @@ -23,6 +27,7 @@ describe('CI Lint Results', () => { const findTable = () => wrapper.find(GlTable); const findByTestId = selector => () => wrapper.find(`[data-testid="ci-lint-${selector}"]`); const findAllByTestId = selector => () => wrapper.findAll(`[data-testid="ci-lint-${selector}"]`); + const findLinkToDoc = () => wrapper.find(GlLink); const findErrors = findByTestId('errors'); const findWarnings = findByTestId('warnings'); const findStatus = findByTestId('status'); @@ -48,10 +53,15 @@ describe('CI Lint Results', () => { }); it('displays the invalid status', () => { - expect(findStatus().text()).toBe(`Status: ${wrapper.vm.$options.incorrect.text}`); + expect(findStatus().text()).toContain(`Status: ${wrapper.vm.$options.incorrect.text}`); expect(findStatus().props('variant')).toBe(wrapper.vm.$options.incorrect.variant); }); + it('contains the link to documentation', () => { + expect(findLinkToDoc().text()).toBe('More information'); + expect(findLinkToDoc().attributes('href')).toBe(defaultProps.lintHelpPagePath); + }); + it('displays the error message', () => { const [expectedError] = mockErrors; @@ -66,9 +76,9 @@ describe('CI Lint Results', () => { }); }); - describe('Valid results', () => { + describe('Valid results with dry run', () => { beforeEach(() => { - createComponent(); + createComponent({ dryRun: true }, mount); }); it('displays table', () => { @@ -76,13 +86,18 @@ describe('CI Lint Results', () => { }); it('displays the valid status', () => { - expect(findStatus().text()).toBe(wrapper.vm.$options.correct.text); + expect(findStatus().text()).toContain(wrapper.vm.$options.correct.text); expect(findStatus().props('variant')).toBe(wrapper.vm.$options.correct.variant); }); it('does not display only/expect values with dry run', () => { expect(findOnlyExcept().exists()).toBe(false); }); + + it('contains the link to documentation', () => { + expect(findLinkToDoc().text()).toBe('More information'); + expect(findLinkToDoc().attributes('href')).toBe(defaultProps.lintHelpPagePath); + }); }); describe('Lint results', () => { diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js index e617cca499d..b353da5910d 100644 --- a/spec/frontend/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci_lint/components/ci_lint_spec.js @@ -1,7 +1,11 @@ +import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; import CiLint from '~/ci_lint/components/ci_lint.vue'; +import CiLintResults from '~/ci_lint/components/ci_lint_results.vue'; import lintCIMutation from '~/ci_lint/graphql/mutations/lint_ci.mutation.graphql'; +import { mockLintDataValid } from '../mock_data'; describe('CI Lint', () => { let wrapper; @@ -9,6 +13,7 @@ describe('CI Lint', () => { const endpoint = '/namespace/project/-/ci/lint'; const content = "test_job:\n stage: build\n script: echo 'Building'\n only:\n - web\n - chat\n - pushes\n allow_failure: true "; + const mockMutate = jest.fn().mockResolvedValue(mockLintDataValid); const createComponent = () => { wrapper = shallowMount(CiLint, { @@ -19,17 +24,20 @@ describe('CI Lint', () => { }, propsData: { endpoint, - helpPagePath: '/help/ci/lint#pipeline-simulation', + pipelineSimulationHelpPagePath: '/help/ci/lint#pipeline-simulation', + lintHelpPagePath: '/help/ci/lint#anchor', }, mocks: { $apollo: { - mutate: jest.fn(), + mutate: mockMutate, }, }, }); }; const findEditor = () => wrapper.find(EditorLite); + const findAlert = () => wrapper.find(GlAlert); + const findCiLintResults = () => wrapper.find(CiLintResults); const findValidateBtn = () => wrapper.find('[data-testid="ci-lint-validate"]'); const findClearBtn = () => wrapper.find('[data-testid="ci-lint-clear"]'); @@ -38,6 +46,7 @@ describe('CI Lint', () => { }); afterEach(() => { + mockMutate.mockClear(); wrapper.destroy(); }); @@ -67,6 +76,35 @@ describe('CI Lint', () => { }); }); + it('validation displays results', async () => { + findValidateBtn().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findValidateBtn().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findCiLintResults().exists()).toBe(true); + expect(findValidateBtn().props('loading')).toBe(false); + }); + + it('validation displays error', async () => { + mockMutate.mockRejectedValue('Error!'); + + findValidateBtn().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findValidateBtn().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findCiLintResults().exists()).toBe(false); + expect(findAlert().text()).toBe('Error!'); + expect(findValidateBtn().props('loading')).toBe(false); + }); + it('content is cleared on clear action', async () => { expect(findEditor().props('value')).toBe(content); diff --git a/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap b/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap new file mode 100644 index 00000000000..87bec82e350 --- /dev/null +++ b/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`~/ci_lint/graphql/resolvers Mutation lintCI resolves lint data with type names 1`] = ` +Object { + "__typename": "CiLintContent", + "errors": Array [], + "jobs": Array [ + Object { + "__typename": "CiLintJob", + "afterScript": Array [ + "echo 'after script 1", + ], + "allowFailure": false, + "beforeScript": Array [ + "echo 'before script 1'", + ], + "environment": "prd", + "except": Object { + "refs": Array [ + "master@gitlab-org/gitlab", + "/^release/.*$/@gitlab-org/gitlab", + ], + }, + "name": "job_1", + "only": null, + "script": Array [ + "echo 'script 1'", + ], + "stage": "test", + "tagList": Array [ + "tag 1", + ], + "when": "on_success", + }, + Object { + "__typename": "CiLintJob", + "afterScript": Array [ + "echo 'after script 2", + ], + "allowFailure": true, + "beforeScript": Array [ + "echo 'before script 2'", + ], + "environment": "stg", + "except": Object { + "refs": Array [ + "master@gitlab-org/gitlab", + "/^release/.*$/@gitlab-org/gitlab", + ], + }, + "name": "job_2", + "only": Object { + "__typename": "CiLintJobOnlyPolicy", + "refs": Array [ + "web", + "chat", + "pushes", + ], + }, + "script": Array [ + "echo 'script 2'", + ], + "stage": "test", + "tagList": Array [ + "tag 2", + ], + "when": "on_success", + }, + ], + "valid": true, + "warnings": Array [], +} +`; diff --git a/spec/frontend/ci_lint/graphql/resolvers_spec.js b/spec/frontend/ci_lint/graphql/resolvers_spec.js new file mode 100644 index 00000000000..437c52cf6b4 --- /dev/null +++ b/spec/frontend/ci_lint/graphql/resolvers_spec.js @@ -0,0 +1,38 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; + +import resolvers from '~/ci_lint/graphql/resolvers'; +import { mockLintResponse } from '../mock_data'; + +describe('~/ci_lint/graphql/resolvers', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('Mutation', () => { + describe('lintCI', () => { + const endpoint = '/ci/lint'; + + beforeEach(() => { + mock.onPost(endpoint).reply(httpStatus.OK, mockLintResponse); + }); + + it('resolves lint data with type names', async () => { + const result = resolvers.Mutation.lintCI(null, { + endpoint, + content: 'content', + dry_run: true, + }); + + await expect(result).resolves.toMatchSnapshot(); + }); + }); + }); +}); diff --git a/spec/frontend/ci_lint/mock_data.js b/spec/frontend/ci_lint/mock_data.js index cf7d69dcad3..b87c9f8413b 100644 --- a/spec/frontend/ci_lint/mock_data.js +++ b/spec/frontend/ci_lint/mock_data.js @@ -1,3 +1,37 @@ +export const mockLintResponse = { + valid: true, + errors: [], + warnings: [], + jobs: [ + { + name: 'job_1', + stage: 'test', + before_script: ["echo 'before script 1'"], + script: ["echo 'script 1'"], + after_script: ["echo 'after script 1"], + tag_list: ['tag 1'], + environment: 'prd', + when: 'on_success', + allow_failure: false, + only: null, + except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + }, + { + name: 'job_2', + stage: 'test', + before_script: ["echo 'before script 2'"], + script: ["echo 'script 2'"], + after_script: ["echo 'after script 2"], + tag_list: ['tag 2'], + environment: 'stg', + when: 'on_success', + allow_failure: true, + only: { refs: ['web', 'chat', 'pushes'] }, + except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + }, + ], +}; + export const mockJobs = [ { name: 'job_1', @@ -47,3 +81,14 @@ export const mockErrors = [ export const mockWarnings = [ '"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"', ]; + +export const mockLintDataValid = { + data: { + lintCI: { + errors: [], + warnings: [], + valid: true, + jobs: mockJobs, + }, + }, +}; diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js deleted file mode 100644 index 93b185bd242..00000000000 --- a/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js +++ /dev/null @@ -1,203 +0,0 @@ -import $ from 'jquery'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list'; - -const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/-/variables'; -const HIDE_CLASS = 'hide'; - -describe('AjaxFormVariableList', () => { - preloadFixtures('projects/ci_cd_settings.html'); - preloadFixtures('projects/ci_cd_settings_with_variables.html'); - - let container; - let saveButton; - let errorBox; - - let mock; - let ajaxVariableList; - - beforeEach(() => { - loadFixtures('projects/ci_cd_settings.html'); - container = document.querySelector('.js-ci-variable-list-section'); - - mock = new MockAdapter(axios); - - const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section'); - saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button'); - errorBox = container.querySelector('.js-ci-variable-error-box'); - ajaxVariableList = new AjaxFormVariableList({ - container, - formField: 'variables', - saveButton, - errorBox, - saveEndpoint: container.dataset.saveEndpoint, - maskableRegex: container.dataset.maskableRegex, - }); - - jest.spyOn(ajaxVariableList, 'updateRowsWithPersistedVariables'); - jest.spyOn(ajaxVariableList.variableList, 'toggleEnableRow'); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('onSaveClicked', () => { - it('shows loading spinner while waiting for the request', () => { - const loadingIcon = saveButton.querySelector('.js-ci-variables-save-loading-icon'); - - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => { - expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(false); - - return [200, {}]; - }); - - expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true); - - return ajaxVariableList.onSaveClicked().then(() => { - expect(loadingIcon.classList.contains(HIDE_CLASS)).toEqual(true); - }); - }); - - it('calls `updateRowsWithPersistedVariables` with the persisted variables', () => { - const variablesResponse = [{ id: 1, key: 'foo', value: 'bar' }]; - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, { - variables: variablesResponse, - }); - - return ajaxVariableList.onSaveClicked().then(() => { - expect(ajaxVariableList.updateRowsWithPersistedVariables).toHaveBeenCalledWith( - variablesResponse, - ); - }); - }); - - it('hides any previous error box', () => { - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200); - - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - - return ajaxVariableList.onSaveClicked().then(() => { - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - }); - }); - - it('disables remove buttons while waiting for the request', () => { - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(() => { - expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(false); - - return [200, {}]; - }); - - return ajaxVariableList.onSaveClicked().then(() => { - expect(ajaxVariableList.variableList.toggleEnableRow).toHaveBeenCalledWith(true); - }); - }); - - it('hides secret values', () => { - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(200, {}); - - const row = container.querySelector('.js-row'); - const valueInput = row.querySelector('.js-ci-variable-input-value'); - const valuePlaceholder = row.querySelector('.js-secret-value-placeholder'); - - valueInput.value = 'bar'; - $(valueInput).trigger('input'); - - expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(true); - expect(valueInput.classList.contains(HIDE_CLASS)).toBe(false); - - return ajaxVariableList.onSaveClicked().then(() => { - expect(valuePlaceholder.classList.contains(HIDE_CLASS)).toBe(false); - expect(valueInput.classList.contains(HIDE_CLASS)).toBe(true); - }); - }); - - it('shows error box with validation errors', () => { - const validationError = 'some validation error'; - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(400, [validationError]); - - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - - return ajaxVariableList.onSaveClicked().then(() => { - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(false); - expect(errorBox.textContent.trim().replace(/\n+\s+/m, ' ')).toEqual( - `Validation failed ${validationError}`, - ); - }); - }); - - it('shows flash message when request fails', () => { - mock.onPatch(VARIABLE_PATCH_ENDPOINT).reply(500); - - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - - return ajaxVariableList.onSaveClicked().then(() => { - expect(errorBox.classList.contains(HIDE_CLASS)).toEqual(true); - }); - }); - }); - - describe('updateRowsWithPersistedVariables', () => { - beforeEach(() => { - loadFixtures('projects/ci_cd_settings_with_variables.html'); - container = document.querySelector('.js-ci-variable-list-section'); - - const ajaxVariableListEl = document.querySelector('.js-ci-variable-list-section'); - saveButton = ajaxVariableListEl.querySelector('.js-ci-variables-save-button'); - errorBox = container.querySelector('.js-ci-variable-error-box'); - ajaxVariableList = new AjaxFormVariableList({ - container, - formField: 'variables', - saveButton, - errorBox, - saveEndpoint: container.dataset.saveEndpoint, - }); - }); - - it('removes variable that was removed', () => { - expect(container.querySelectorAll('.js-row').length).toBe(3); - - container.querySelector('.js-row-remove-button').click(); - - expect(container.querySelectorAll('.js-row').length).toBe(3); - - ajaxVariableList.updateRowsWithPersistedVariables([]); - - expect(container.querySelectorAll('.js-row').length).toBe(2); - }); - - it('updates new variable row with persisted ID', () => { - const row = container.querySelector('.js-row:last-child'); - const idInput = row.querySelector('.js-ci-variable-input-id'); - const keyInput = row.querySelector('.js-ci-variable-input-key'); - const valueInput = row.querySelector('.js-ci-variable-input-value'); - - keyInput.value = 'foo'; - $(keyInput).trigger('input'); - valueInput.value = 'bar'; - $(valueInput).trigger('input'); - - expect(idInput.value).toEqual(''); - - ajaxVariableList.updateRowsWithPersistedVariables([ - { - id: 3, - key: 'foo', - value: 'bar', - }, - ]); - - expect(idInput.value).toEqual('3'); - expect(row.dataset.isPersisted).toEqual('true'); - }); - }); - - describe('maskableRegex', () => { - it('takes in the regex provided by the data attribute', () => { - expect(container.dataset.maskableRegex).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$'); - expect(ajaxVariableList.maskableRegex).toBe(container.dataset.maskableRegex); - }); - }); -}); diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js index 9508203e5c2..4a2e56c570d 100644 --- a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js +++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import waitForPromises from 'helpers/wait_for_promises'; import VariableList from '~/ci_variable_list/ci_variable_list'; const HIDE_CLASS = 'hide'; @@ -7,7 +6,6 @@ const HIDE_CLASS = 'hide'; describe('VariableList', () => { preloadFixtures('pipeline_schedules/edit.html'); preloadFixtures('pipeline_schedules/edit_with_variables.html'); - preloadFixtures('projects/ci_cd_settings.html'); let $wrapper; let variableList; @@ -113,92 +111,6 @@ describe('VariableList', () => { }); }); - describe('with all inputs(key, value, protected)', () => { - beforeEach(() => { - loadFixtures('projects/ci_cd_settings.html'); - $wrapper = $('.js-ci-variable-list-section'); - - $wrapper.find('.js-ci-variable-input-protected').attr('data-default', 'false'); - - variableList = new VariableList({ - container: $wrapper, - formField: 'variables', - }); - variableList.init(); - }); - - it('should not add another row when editing the last rows protected checkbox', () => { - const $row = $wrapper.find('.js-row:last-child'); - $row.find('.ci-variable-protected-item .js-project-feature-toggle').click(); - - return waitForPromises().then(() => { - expect($wrapper.find('.js-row').length).toBe(1); - }); - }); - - it('should not add another row when editing the last rows masked checkbox', () => { - jest.spyOn(variableList, 'checkIfRowTouched'); - const $row = $wrapper.find('.js-row:last-child'); - $row.find('.ci-variable-masked-item .js-project-feature-toggle').click(); - - return waitForPromises().then(() => { - // This validates that we are checking after the event listener has run - expect(variableList.checkIfRowTouched).toHaveBeenCalled(); - expect($wrapper.find('.js-row').length).toBe(1); - }); - }); - - describe('validateMaskability', () => { - let $row; - - const maskingErrorElement = '.js-row:last-child .masking-validation-error'; - const clickToggle = () => - $row.find('.ci-variable-masked-item .js-project-feature-toggle').click(); - - beforeEach(() => { - $row = $wrapper.find('.js-row:last-child'); - }); - - it('has a regex provided via a data attribute', () => { - clickToggle(); - - expect($wrapper.attr('data-maskable-regex')).toBe('^[a-zA-Z0-9_+=/@:.-]{8,}$'); - }); - - it('allows values that are 8 characters long', () => { - $row.find('.js-ci-variable-input-value').val('looooong'); - - clickToggle(); - - expect($wrapper.find(maskingErrorElement)).toHaveClass('hide'); - }); - - it('rejects values that are shorter than 8 characters', () => { - $row.find('.js-ci-variable-input-value').val('short'); - - clickToggle(); - - expect($wrapper.find(maskingErrorElement)).toBeVisible(); - }); - - it('allows values with base 64 characters', () => { - $row.find('.js-ci-variable-input-value').val('abcABC123_+=/-'); - - clickToggle(); - - expect($wrapper.find(maskingErrorElement)).toHaveClass('hide'); - }); - - it('rejects values with other special characters', () => { - $row.find('.js-ci-variable-input-value').val('1234567$'); - - clickToggle(); - - expect($wrapper.find(maskingErrorElement)).toBeVisible(); - }); - }); - }); - describe('toggleEnableRow method', () => { beforeEach(() => { loadFixtures('pipeline_schedules/edit_with_variables.html'); @@ -247,36 +159,4 @@ describe('VariableList', () => { expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); }); }); - - describe('hideValues', () => { - beforeEach(() => { - loadFixtures('projects/ci_cd_settings.html'); - $wrapper = $('.js-ci-variable-list-section'); - - variableList = new VariableList({ - container: $wrapper, - formField: 'variables', - }); - variableList.init(); - }); - - it('should hide value input and show placeholder stars', () => { - const $row = $wrapper.find('.js-row'); - const $inputValue = $row.find('.js-ci-variable-input-value'); - const $placeholder = $row.find('.js-secret-value-placeholder'); - - $row - .find('.js-ci-variable-input-value') - .val('foo') - .trigger('input'); - - expect($placeholder.hasClass(HIDE_CLASS)).toBe(true); - expect($inputValue.hasClass(HIDE_CLASS)).toBe(false); - - variableList.hideValues(); - - expect($placeholder.hasClass(HIDE_CLASS)).toBe(false); - expect($inputValue.hasClass(HIDE_CLASS)).toBe(true); - }); - }); }); diff --git a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap index b6e89281fef..744ef318260 100644 --- a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap @@ -73,7 +73,7 @@ exports[`Applications Ingress application shows the correct warning message 1`] exports[`Applications Knative application shows the correct description 1`] = ` <span - data-testid="installedVia" + data-testid="installed-via" > installed via <a diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index 15eeadcc8b8..de40e03b598 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -14,6 +14,8 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ > <!----> + <!----> + <span class="gl-new-dropdown-button-text" > diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index e0ccf36e868..5438f3053a8 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -429,7 +429,7 @@ describe('Applications', () => { await wrapper.vm.$nextTick(); - expect(findByTestId('installedVia').element).toMatchSnapshot(); + expect(findByTestId('installed-via').element).toMatchSnapshot(); }); it('emits saveKnativeDomain event when knative domain editor emits save event', () => { diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap index 62b751ec59b..62e527a2c5f 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -12,7 +12,7 @@ exports[`Code navigation popover component renders popover 1`] = ` <gl-tabs-stub contentclass="gl-py-0" - nav-class="gl-hidden" + navclass="gl-hidden" theme="indigo" > <gl-tab-stub diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js index 2600415fc9f..f9984091df0 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js @@ -16,7 +16,6 @@ describe('EksClusterConfigurationForm', () => { let getters; let state; let rolesState; - let regionsState; let vpcsState; let subnetsState; let keyPairsState; @@ -24,7 +23,6 @@ describe('EksClusterConfigurationForm', () => { let instanceTypesState; let vpcsActions; let rolesActions; - let regionsActions; let subnetsActions; let keyPairsActions; let securityGroupsActions; @@ -46,9 +44,6 @@ describe('EksClusterConfigurationForm', () => { setNodeCount: jest.fn(), setGitlabManagedCluster: jest.fn(), }; - regionsActions = { - fetchItems: jest.fn(), - }; keyPairsActions = { fetchItems: jest.fn(), }; @@ -72,10 +67,6 @@ describe('EksClusterConfigurationForm', () => { ...clusterDropdownStoreState(), ...config.rolesState, }; - regionsState = { - ...clusterDropdownStoreState(), - ...config.regionsState, - }; vpcsState = { ...clusterDropdownStoreState(), ...config.vpcsState, @@ -109,11 +100,6 @@ describe('EksClusterConfigurationForm', () => { state: vpcsState, actions: vpcsActions, }, - regions: { - namespaced: true, - state: regionsState, - actions: regionsActions, - }, subnets: { namespaced: true, state: subnetsState, @@ -189,7 +175,6 @@ describe('EksClusterConfigurationForm', () => { const findClusterNameInput = () => vm.find('[id=eks-cluster-name]'); const findEnvironmentScopeInput = () => vm.find('[id=eks-environment-scope]'); const findKubernetesVersionDropdown = () => vm.find('[field-id="eks-kubernetes-version"]'); - const findRegionDropdown = () => vm.find('[field-id="eks-region"]'); const findKeyPairDropdown = () => vm.find('[field-id="eks-key-pair"]'); const findVpcDropdown = () => vm.find('[field-id="eks-vpc"]'); const findSubnetDropdown = () => vm.find('[field-id="eks-subnet"]'); @@ -200,13 +185,44 @@ describe('EksClusterConfigurationForm', () => { const findGitlabManagedClusterCheckbox = () => vm.find(GlFormCheckbox); describe('when mounted', () => { - it('fetches available regions', () => { - expect(regionsActions.fetchItems).toHaveBeenCalled(); - }); - it('fetches available roles', () => { expect(rolesActions.fetchItems).toHaveBeenCalled(); }); + + describe('when fetching vpcs and key pairs', () => { + const region = 'us-west-2'; + + beforeEach(() => { + createValidStateStore({ selectedRegion: region }); + buildWrapper(); + }); + + it('fetches available vpcs', () => { + expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }); + }); + + it('fetches available key pairs', () => { + expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }); + }); + + it('cleans selected vpc', () => { + expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }); + }); + + it('cleans selected key pair', () => { + expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair: null }); + }); + + it('cleans selected subnet', () => { + expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }); + }); + + it('cleans selected security group', () => { + expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { + securityGroup: null, + }); + }); + }); }); it('sets isLoadingRoles to RoleDropdown loading property', () => { @@ -229,26 +245,6 @@ describe('EksClusterConfigurationForm', () => { }); }); - it('sets isLoadingRegions to RegionDropdown loading property', () => { - regionsState.isLoadingItems = true; - - return Vue.nextTick().then(() => { - expect(findRegionDropdown().props('loading')).toBe(regionsState.isLoadingItems); - }); - }); - - it('sets regions to RegionDropdown regions property', () => { - expect(findRegionDropdown().props('items')).toBe(regionsState.items); - }); - - it('sets loadingRegionsError to RegionDropdown error property', () => { - regionsState.loadingItemsError = new Error(); - - return Vue.nextTick().then(() => { - expect(findRegionDropdown().props('hasErrors')).toEqual(true); - }); - }); - it('disables KeyPairDropdown when no region is selected', () => { expect(findKeyPairDropdown().props('disabled')).toBe(true); }); @@ -394,44 +390,6 @@ describe('EksClusterConfigurationForm', () => { }); }); - describe('when region is selected', () => { - const region = { name: 'us-west-2' }; - - beforeEach(() => { - findRegionDropdown().vm.$emit('input', region); - }); - - it('dispatches setRegion action', () => { - expect(actions.setRegion).toHaveBeenCalledWith(expect.anything(), { region }); - }); - - it('fetches available vpcs', () => { - expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }); - }); - - it('fetches available key pairs', () => { - expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }); - }); - - it('cleans selected vpc', () => { - expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }); - }); - - it('cleans selected key pair', () => { - expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair: null }); - }); - - it('cleans selected subnet', () => { - expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }); - }); - - it('cleans selected security group', () => { - expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { - securityGroup: null, - }); - }); - }); - it('dispatches setClusterName when cluster name input changes', () => { const clusterName = 'name'; diff --git a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js index 0ef09b4b87e..03c22c570a8 100644 --- a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js @@ -3,12 +3,10 @@ import EC2 from 'aws-sdk/clients/ec2'; import { setAWSConfig, fetchRoles, - fetchRegions, fetchKeyPairs, fetchVpcs, fetchSubnets, fetchSecurityGroups, - DEFAULT_REGION, } from '~/create_cluster/eks_cluster/services/aws_services_facade'; const mockListRolesPromise = jest.fn(); @@ -45,19 +43,17 @@ describe('awsServicesFacade', () => { vpc = 'vpc-2'; }); - it('setAWSConfig configures AWS SDK with provided credentials and default region', () => { + it('setAWSConfig configures AWS SDK with provided credentials', () => { const awsCredentials = { accessKeyId: 'access-key', secretAccessKey: 'secret-key', sessionToken: 'session-token', + region, }; setAWSConfig({ awsCredentials }); - expect(AWS.config).toEqual({ - ...awsCredentials, - region: DEFAULT_REGION, - }); + expect(AWS.config).toEqual(awsCredentials); }); describe('when fetchRoles succeeds', () => { @@ -79,22 +75,6 @@ describe('awsServicesFacade', () => { }); }); - describe('when fetchRegions succeeds', () => { - let regions; - let regionsOutput; - - beforeEach(() => { - regions = [{ RegionName: 'east-1' }, { RegionName: 'west-2' }]; - regionsOutput = regions.map(({ RegionName: name }) => ({ name, value: name })); - - mockDescribeRegionsPromise.mockResolvedValueOnce({ Regions: regions }); - }); - - it('return list of roles where each item has a name and value', () => { - return expect(fetchRegions()).resolves.toEqual(regionsOutput); - }); - }); - describe('when fetchKeyPairs succeeds', () => { let keyPairs; let keyPairsOutput; diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index f929216689a..f12f300872a 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -23,6 +23,7 @@ import { REQUEST_CREATE_CLUSTER, CREATE_CLUSTER_ERROR, } from '~/create_cluster/eks_cluster/store/mutation_types'; +import { DEFAULT_REGION } from '~/create_cluster/eks_cluster/constants'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; @@ -109,12 +110,13 @@ describe('EKS Cluster Store Actions', () => { secretAccessKey: 'secret-key-id', }; - describe('when request succeeds', () => { + describe('when request succeeds with default region', () => { beforeEach(() => { mock .onPost(state.createRolePath, { role_arn: payload.roleArn, role_external_id: payload.externalId, + region: DEFAULT_REGION, }) .reply(201, response); }); @@ -125,7 +127,51 @@ describe('EKS Cluster Store Actions', () => { payload, state, [], - [{ type: 'requestCreateRole' }, { type: 'createRoleSuccess', payload: response }], + [ + { type: 'requestCreateRole' }, + { + type: 'createRoleSuccess', + payload: { + region: DEFAULT_REGION, + ...response, + }, + }, + ], + )); + }); + + describe('when request succeeds with custom region', () => { + const customRegion = 'custom-region'; + + beforeEach(() => { + mock + .onPost(state.createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + region: customRegion, + }) + .reply(201, response); + }); + + it('dispatches createRoleSuccess action', () => + testAction( + actions.createRole, + { + selectedRegion: customRegion, + ...payload, + }, + state, + [], + [ + { type: 'requestCreateRole' }, + { + type: 'createRoleSuccess', + payload: { + region: customRegion, + ...response, + }, + }, + ], )); }); @@ -138,6 +184,7 @@ describe('EKS Cluster Store Actions', () => { .onPost(state.createRolePath, { role_arn: payload.roleArn, role_external_id: payload.externalId, + region: DEFAULT_REGION, }) .reply(400, error); }); @@ -160,8 +207,14 @@ describe('EKS Cluster Store Actions', () => { }); describe('createRoleSuccess', () => { - it('commits createRoleSuccess mutation', () => { - testAction(actions.createRoleSuccess, null, state, [{ type: CREATE_ROLE_SUCCESS }]); + it('sets region and commits createRoleSuccess mutation', () => { + testAction( + actions.createRoleSuccess, + { region }, + state, + [{ type: CREATE_ROLE_SUCCESS }], + [{ type: 'setRegion', payload: { region } }], + ); }); }); diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js index 9ecf6bf375b..9f28ddfd230 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js @@ -4,6 +4,7 @@ import { GlButton, GlModal } from '@gitlab/ui'; import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; import createStore from '~/deploy_freeze/store'; +import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -11,8 +12,6 @@ localVue.use(Vuex); describe('Deploy freeze modal', () => { let wrapper; let store; - const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); - const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); beforeEach(() => { store = createStore({ diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js index d40df7de7d1..c29a0c0ca73 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js @@ -4,6 +4,7 @@ import DeployFreezeSettings from '~/deploy_freeze/components/deploy_freeze_setti import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue'; import createStore from '~/deploy_freeze/store'; +import { timezoneDataFixture } from '../helpers'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -11,7 +12,6 @@ localVue.use(Vuex); describe('Deploy freeze settings', () => { let wrapper; let store; - const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); beforeEach(() => { store = createStore({ diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js index 383ffa90b22..8480705b5e3 100644 --- a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js @@ -2,6 +2,7 @@ import Vuex from 'vuex'; import { createLocalVue, mount } from '@vue/test-utils'; import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; import createStore from '~/deploy_freeze/store'; +import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -9,7 +10,6 @@ localVue.use(Vuex); describe('Deploy freeze table', () => { let wrapper; let store; - const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); const createComponent = () => { store = createStore({ @@ -50,7 +50,6 @@ describe('Deploy freeze table', () => { }); it('displays data', () => { - const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); store.state.freezePeriods = freezePeriodsFixture; return wrapper.vm.$nextTick(() => { diff --git a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js index d1219627ca7..2aa977dfa5a 100644 --- a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js +++ b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js @@ -1,9 +1,9 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { GlDropdownItem, GlDropdown } from '@gitlab/ui'; -import { secondsToHours } from '~/lib/utils/datetime_utility'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; import createStore from '~/deploy_freeze/store'; +import { findTzByName, formatTz, timezoneDataFixture } from '../helpers'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -11,12 +11,6 @@ localVue.use(Vuex); describe('Deploy freeze timezone dropdown', () => { let wrapper; let store; - const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); - - const findTzByName = (identifier = '') => - timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase()); - - const formatTz = ({ offset, name }) => `[UTC ${secondsToHours(offset)}] ${name}`; const createComponent = (searchTerm, selectedTimezone) => { store = createStore({ diff --git a/spec/frontend/deploy_freeze/helpers.js b/spec/frontend/deploy_freeze/helpers.js new file mode 100644 index 00000000000..bfb84142662 --- /dev/null +++ b/spec/frontend/deploy_freeze/helpers.js @@ -0,0 +1,9 @@ +import { secondsToHours } from '~/lib/utils/datetime_utility'; + +export const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); +export const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); + +export const findTzByName = (identifier = '') => + timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase()); + +export const formatTz = ({ offset, name }) => `[UTC ${secondsToHours(offset)}] ${name}`; diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js index 97f94cdbf5e..3c9d25c4f5c 100644 --- a/spec/frontend/deploy_freeze/store/actions_spec.js +++ b/spec/frontend/deploy_freeze/store/actions_spec.js @@ -6,6 +6,7 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; import getInitialState from '~/deploy_freeze/store/state'; import * as actions from '~/deploy_freeze/store/actions'; import * as types from '~/deploy_freeze/store/mutation_types'; +import { freezePeriodsFixture, timezoneDataFixture } from '../helpers'; jest.mock('~/api.js'); jest.mock('~/flash.js'); @@ -13,8 +14,6 @@ jest.mock('~/flash.js'); describe('deploy freeze store actions', () => { let mock; let state; - const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); - const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); beforeEach(() => { mock = new MockAdapter(axios); diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js index 0453e037e15..7cb208f16b2 100644 --- a/spec/frontend/deploy_freeze/store/mutations_spec.js +++ b/spec/frontend/deploy_freeze/store/mutations_spec.js @@ -2,10 +2,10 @@ import state from '~/deploy_freeze/store/state'; import mutations from '~/deploy_freeze/store/mutations'; import * as types from '~/deploy_freeze/store/mutation_types'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { findTzByName, formatTz, freezePeriodsFixture, timezoneDataFixture } from '../helpers'; describe('Deploy freeze mutations', () => { let stateCopy; - const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); beforeEach(() => { stateCopy = state({ @@ -28,7 +28,6 @@ describe('Deploy freeze mutations', () => { describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => { it('should set freeze periods and format timezones from identifiers to names', () => { const timezoneNames = ['Berlin', 'UTC', 'Eastern Time (US & Canada)']; - const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture); @@ -43,9 +42,10 @@ describe('Deploy freeze mutations', () => { describe('SET_SELECTED_TIMEZONE', () => { it('should set the cron timezone', () => { + const selectedTz = findTzByName('Pacific Time (US & Canada)'); const timezone = { - formattedTimezone: '[UTC -7] Pacific Time (US & Canada)', - identifier: 'America/Los_Angeles', + formattedTimezone: formatTz(selectedTz), + identifier: selectedTz.identifier, }; mutations[types.SET_SELECTED_TIMEZONE](stateCopy, timezone); diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js index 0b1cbd28274..d990c64c241 100644 --- a/spec/frontend/deploy_keys/components/key_spec.js +++ b/spec/frontend/deploy_keys/components/key_spec.js @@ -79,7 +79,7 @@ describe('Deploy keys key', () => { deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: true }; createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } }); - expect(wrapper.find('.deploy-project-label').attributes('data-original-title')).toBe( + expect(wrapper.find('.deploy-project-label').attributes('title')).toBe( 'Write access allowed', ); }); @@ -88,9 +88,7 @@ describe('Deploy keys key', () => { deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: false }; createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } }); - expect(wrapper.find('.deploy-project-label').attributes('data-original-title')).toBe( - 'Read access only', - ); + expect(wrapper.find('.deploy-project-label').attributes('title')).toBe('Read access only'); }); it('shows expandable button if more than two projects', () => { @@ -99,7 +97,7 @@ describe('Deploy keys key', () => { expect(labels.length).toBe(2); expect(labels.at(1).text()).toContain('others'); - expect(labels.at(1).attributes('data-original-title')).toContain('Expand'); + expect(labels.at(1).attributes('title')).toContain('Expand'); }); it('expands all project labels after click', () => { @@ -115,7 +113,7 @@ describe('Deploy keys key', () => { expect(labels.length).toBe(length); expect(labels.at(1).text()).not.toContain(`+${length} others`); - expect(labels.at(1).attributes('data-original-title')).not.toContain('Expand'); + expect(labels.at(1).attributes('title')).not.toContain('Expand'); }); }); diff --git a/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap deleted file mode 100644 index 0679b485f77..00000000000 --- a/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap +++ /dev/null @@ -1,115 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = ` -<div - class="design-scaler btn-group" - role="group" -> - <button - class="btn" - disabled="disabled" - > - <span - class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16" - > - - – - - </span> - </button> - - <button - class="btn" - disabled="disabled" - > - <gl-icon-stub - name="redo" - size="16" - /> - </button> - - <button - class="btn" - > - <gl-icon-stub - name="plus" - size="16" - /> - </button> -</div> -`; - -exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = ` -<div - class="design-scaler btn-group" - role="group" -> - <button - class="btn" - > - <span - class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16" - > - - – - - </span> - </button> - - <button - class="btn" - > - <gl-icon-stub - name="redo" - size="16" - /> - </button> - - <button - class="btn" - > - <gl-icon-stub - name="plus" - size="16" - /> - </button> -</div> -`; - -exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = ` -<div - class="design-scaler btn-group" - role="group" -> - <button - class="btn" - > - <span - class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16" - > - - – - - </span> - </button> - - <button - class="btn" - > - <gl-icon-stub - name="redo" - size="16" - /> - </button> - - <button - class="btn" - disabled="disabled" - > - <gl-icon-stub - name="plus" - size="16" - /> - </button> -</div> -`; diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index 4ef067e3f5e..f4fd4c70dfc 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -243,11 +243,11 @@ describe('Design overlay component', () => { }); }); - describe('without [adminNote] permission', () => { + describe('without [repositionNote] permission', () => { const mockNoteNotAuthorised = { ...notes[0], userPermissions: { - adminNote: false, + repositionNote: false, }, }; @@ -412,18 +412,18 @@ describe('Design overlay component', () => { describe('canMoveNote', () => { it.each` - adminNotePermission | canMoveNoteResult - ${true} | ${true} - ${false} | ${false} - ${undefined} | ${false} + repositionNotePermission | canMoveNoteResult + ${true} | ${true} + ${false} | ${false} + ${undefined} | ${false} `( - 'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]', - ({ adminNotePermission, canMoveNoteResult }) => { + 'returns [$canMoveNoteResult] when [repositionNote permission] is [$repositionNotePermission]', + ({ repositionNotePermission, canMoveNoteResult }) => { createComponent(); const note = { userPermissions: { - adminNote: adminNotePermission, + repositionNote: repositionNotePermission, }, }; expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult); diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js index b06d2f924df..290ec3a18e3 100644 --- a/spec/frontend/design_management/components/design_scaler_spec.js +++ b/spec/frontend/design_management/components/design_scaler_spec.js @@ -1,67 +1,93 @@ import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import DesignScaler from '~/design_management/components/design_scaler.vue'; describe('Design management design scaler component', () => { let wrapper; - function createComponent(propsData, data = {}) { - wrapper = shallowMount(DesignScaler, { - propsData, - }); - wrapper.setData(data); - } + const getButtons = () => wrapper.findAll(GlButton); + const getDecreaseScaleButton = () => getButtons().at(0); + const getResetScaleButton = () => getButtons().at(1); + const getIncreaseScaleButton = () => getButtons().at(2); - afterEach(() => { - wrapper.destroy(); - }); + const setScale = scale => wrapper.vm.setScale(scale); - const getButton = type => { - const buttonTypeOrder = ['minus', 'reset', 'plus']; - const buttons = wrapper.findAll('button'); - return buttons.at(buttonTypeOrder.indexOf(type)); + const createComponent = () => { + wrapper = shallowMount(DesignScaler); }; - it('emits @scale event when "plus" button clicked', () => { + beforeEach(() => { createComponent(); + }); - getButton('plus').trigger('click'); - expect(wrapper.emitted('scale')).toEqual([[1.2]]); + afterEach(() => { + wrapper.destroy(); + wrapper = null; }); - it('emits @scale event when "reset" button clicked (scale > 1)', () => { - createComponent({}, { scale: 1.6 }); - return wrapper.vm.$nextTick().then(() => { - getButton('reset').trigger('click'); - expect(wrapper.emitted('scale')).toEqual([[1]]); + describe('when `scale` value is greater than 1', () => { + beforeEach(async () => { + setScale(1.6); + await wrapper.vm.$nextTick(); }); - }); - it('emits @scale event when "minus" button clicked (scale > 1)', () => { - createComponent({}, { scale: 1.6 }); + it('emits @scale event when "reset" button clicked', () => { + getResetScaleButton().vm.$emit('click'); + expect(wrapper.emitted('scale')[1]).toEqual([1]); + }); - return wrapper.vm.$nextTick().then(() => { - getButton('minus').trigger('click'); - expect(wrapper.emitted('scale')).toEqual([[1.4]]); + it('emits @scale event when "decrement" button clicked', async () => { + getDecreaseScaleButton().vm.$emit('click'); + expect(wrapper.emitted('scale')[1]).toEqual([1.4]); }); - }); - it('minus and reset buttons are disabled when scale === 1', () => { - createComponent(); + it('enables the "reset" button', () => { + const resetButton = getResetScaleButton(); + + expect(resetButton.exists()).toBe(true); + expect(resetButton.props('disabled')).toBe(false); + }); + + it('enables the "decrement" button', () => { + const decrementButton = getDecreaseScaleButton(); - expect(wrapper.element).toMatchSnapshot(); + expect(decrementButton.exists()).toBe(true); + expect(decrementButton.props('disabled')).toBe(false); + }); + }); + + it('emits @scale event when "plus" button clicked', () => { + getIncreaseScaleButton().vm.$emit('click'); + expect(wrapper.emitted('scale')).toEqual([[1.2]]); }); - it('minus and reset buttons are enabled when scale > 1', () => { - createComponent({}, { scale: 1.2 }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); + describe('when `scale` value is 1', () => { + it('disables the "reset" button', () => { + const resetButton = getResetScaleButton(); + + expect(resetButton.exists()).toBe(true); + expect(resetButton.props('disabled')).toBe(true); + }); + + it('disables the "decrement" button', () => { + const decrementButton = getDecreaseScaleButton(); + + expect(decrementButton.exists()).toBe(true); + expect(decrementButton.props('disabled')).toBe(true); }); }); - it('plus button is disabled when scale === 2', () => { - createComponent({}, { scale: 2 }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); + describe('when `scale` value is 2 (maximum)', () => { + beforeEach(async () => { + setScale(2); + await wrapper.vm.$nextTick(); + }); + + it('disables the "increment" button', () => { + const incrementButton = getIncreaseScaleButton(); + + expect(incrementButton.exists()).toBe(true); + expect(incrementButton.props('disabled')).toBe(true); }); }); }); diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap index 723ac0491a7..e2ad4c68bea 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap @@ -46,6 +46,7 @@ exports[`Design management toolbar component renders design and updated data 1`] href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d" icon="download" size="medium" + title="Download design" variant="default" /> @@ -57,6 +58,7 @@ exports[`Design management toolbar component renders design and updated data 1`] buttonvariant="warning" class="gl-ml-3" hasselecteddesigns="true" + title="Archive design" /> </header> `; diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap index eaa7460ae15..2f857247303 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap @@ -14,41 +14,7 @@ exports[`Design management upload button component renders inverted upload desig > Upload designs - - <!----> - </gl-button-stub> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> -</div> -`; - -exports[`Design management upload button component renders loading icon 1`] = ` -<div> - <gl-button-stub - buttontextclasses="" - category="primary" - disabled="true" - icon="" - size="small" - title="Adding a design with the same filename replaces the file in a new version." - variant="default" - > - - Upload designs - - <gl-loading-icon-stub - class="ml-1" - color="orange" - inline="true" - label="Loading" - size="sm" - /> + </gl-button-stub> <input @@ -73,8 +39,7 @@ exports[`Design management upload button component renders upload design button > Upload designs - - <!----> + </gl-button-stub> <input diff --git a/spec/frontend/design_management/components/upload/button_spec.js b/spec/frontend/design_management/components/upload/button_spec.js index c0a9693dc37..ea738496ad6 100644 --- a/spec/frontend/design_management/components/upload/button_spec.js +++ b/spec/frontend/design_management/components/upload/button_spec.js @@ -1,10 +1,11 @@ import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import UploadButton from '~/design_management/components/upload/button.vue'; describe('Design management upload button component', () => { let wrapper; - function createComponent(isSaving = false, isInverted = false) { + function createComponent({ isSaving = false, isInverted = false } = {}) { wrapper = shallowMount(UploadButton, { propsData: { isSaving, @@ -24,15 +25,19 @@ describe('Design management upload button component', () => { }); it('renders inverted upload design button', () => { - createComponent(false, true); + createComponent({ isInverted: true }); expect(wrapper.element).toMatchSnapshot(); }); - it('renders loading icon', () => { - createComponent(true); + describe('when `isSaving` prop is `true`', () => { + it('Button `loading` prop is `true`', () => { + createComponent({ isSaving: true }); - expect(wrapper.element).toMatchSnapshot(); + const button = wrapper.find(GlButton); + expect(button.exists()).toBe(true); + expect(button.props('loading')).toBe(true); + }); }); describe('onFileUploadChange', () => { diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index 5e41210221b..e53ad2e6afe 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -1,13 +1,18 @@ export const designListQueryResponse = { data: { project: { + __typename: 'Project', id: '1', issue: { + __typename: 'Issue', designCollection: { + __typename: 'DesignCollection', copyState: 'READY', designs: { + __typename: 'DesignConnection', nodes: [ { + __typename: 'Design', id: '1', event: 'NONE', filename: 'fox_1.jpg', @@ -15,10 +20,12 @@ export const designListQueryResponse = { image: 'image-1', imageV432x230: 'image-1', currentUserTodos: { + __typename: 'ToDo', nodes: [], }, }, { + __typename: 'Design', id: '2', event: 'NONE', filename: 'fox_2.jpg', @@ -26,10 +33,12 @@ export const designListQueryResponse = { image: 'image-2', imageV432x230: 'image-2', currentUserTodos: { + __typename: 'ToDo', nodes: [], }, }, { + __typename: 'Design', id: '3', event: 'NONE', filename: 'fox_3.jpg', @@ -37,12 +46,14 @@ export const designListQueryResponse = { image: 'image-3', imageV432x230: 'image-3', currentUserTodos: { + __typename: 'ToDo', nodes: [], }, }, ], }, versions: { + __typename: 'DesignVersion', nodes: [], }, }, @@ -82,9 +93,11 @@ export const designUploadMutationUpdatedResponse = { export const permissionsQueryResponse = { data: { project: { + __typename: 'Project', id: '1', issue: { - userPermissions: { createDesign: true }, + __typename: 'Issue', + userPermissions: { __typename: 'UserPermissions', createDesign: true }, }, }, }, @@ -92,6 +105,7 @@ export const permissionsQueryResponse = { export const reorderedDesigns = [ { + __typename: 'Design', id: '2', event: 'NONE', filename: 'fox_2.jpg', @@ -99,10 +113,12 @@ export const reorderedDesigns = [ image: 'image-2', imageV432x230: 'image-2', currentUserTodos: { + __typename: 'ToDo', nodes: [], }, }, { + __typename: 'Design', id: '1', event: 'NONE', filename: 'fox_1.jpg', @@ -110,10 +126,12 @@ export const reorderedDesigns = [ image: 'image-1', imageV432x230: 'image-1', currentUserTodos: { + __typename: 'ToDo', nodes: [], }, }, { + __typename: 'Design', id: '3', event: 'NONE', filename: 'fox_3.jpg', @@ -121,6 +139,7 @@ export const reorderedDesigns = [ image: 'image-3', imageV432x230: 'image-3', currentUserTodos: { + __typename: 'ToDo', nodes: [], }, }, @@ -130,7 +149,9 @@ export const moveDesignMutationResponse = { data: { designManagementMove: { designCollection: { + __typename: 'DesignCollection', designs: { + __typename: 'DesignConnection', nodes: [...reorderedDesigns], }, }, diff --git a/spec/frontend/design_management/mock_data/discussion.js b/spec/frontend/design_management/mock_data/discussion.js index fbf9a2fdcc1..0e59ef29f8f 100644 --- a/spec/frontend/design_management/mock_data/discussion.js +++ b/spec/frontend/design_management/mock_data/discussion.js @@ -18,7 +18,7 @@ export default { }, createdAt: '2020-05-08T07:10:45Z', userPermissions: { - adminNote: true, + repositionNote: true, }, resolved: false, }, diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap index 2d29b79e31c..abd455ae750 100644 --- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -1,240 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Design management index page designs does not render toolbar when there is no permission 1`] = ` -<div - class="gl-mt-5" - data-testid="designs-root" -> - <!----> - - <div - class="gl-mt-6" - > - <ol - class="list-unstyled row" - > - <li - class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3" - data-testid="design-dropzone-wrapper" - > - <design-dropzone-stub - class="design-list-item design-list-item-new" - data-qa-selector="design_dropzone_content" - hasdesigns="true" - /> - </li> - <li - class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" - > - <design-dropzone-stub - hasdesigns="true" - > - <design-stub - class="gl-bg-white" - event="NONE" - filename="design-1-name" - id="design-1" - image="design-1-image" - notescount="0" - /> - </design-dropzone-stub> - - <!----> - </li> - <li - class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" - > - <design-dropzone-stub - hasdesigns="true" - > - <design-stub - class="gl-bg-white" - event="NONE" - filename="design-2-name" - id="design-2" - image="design-2-image" - notescount="1" - /> - </design-dropzone-stub> - - <!----> - </li> - <li - class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" - > - <design-dropzone-stub - hasdesigns="true" - > - <design-stub - class="gl-bg-white" - event="NONE" - filename="design-3-name" - id="design-3" - image="design-3-image" - notescount="0" - /> - </design-dropzone-stub> - - <!----> - </li> - </ol> - </div> - - <router-view-stub - name="default" - /> -</div> -`; - -exports[`Design management index page designs renders designs list and header with upload button 1`] = ` -<div - class="gl-mt-5" - data-testid="designs-root" -> - <header - class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex" - > - <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full" - > - <div> - <span - class="gl-font-weight-bold gl-mr-3" - > - Designs - </span> - - <design-version-dropdown-stub /> - </div> - - <div - class="qa-selector-toolbar gl-display-flex gl-align-items-center" - > - <gl-button-stub - buttontextclasses="" - category="primary" - class="gl-mr-4 js-select-all" - icon="" - size="small" - variant="link" - > - Select all - - </gl-button-stub> - - <div> - <delete-button-stub - buttoncategory="secondary" - buttonclass="gl-mr-3" - buttonsize="small" - buttonvariant="warning" - data-qa-selector="archive_button" - > - - Archive selected - - </delete-button-stub> - </div> - - <upload-button-stub /> - </div> - </div> - </header> - - <div - class="gl-mt-6" - > - <ol - class="list-unstyled row" - > - <li - class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3" - data-testid="design-dropzone-wrapper" - > - <design-dropzone-stub - class="design-list-item design-list-item-new" - data-qa-selector="design_dropzone_content" - hasdesigns="true" - /> - </li> - <li - class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" - > - <design-dropzone-stub - hasdesigns="true" - > - <design-stub - class="gl-bg-white" - event="NONE" - filename="design-1-name" - id="design-1" - image="design-1-image" - notescount="0" - /> - </design-dropzone-stub> - - <input - class="design-checkbox" - data-qa-design="design-1-name" - data-qa-selector="design_checkbox" - type="checkbox" - /> - </li> - <li - class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" - > - <design-dropzone-stub - hasdesigns="true" - > - <design-stub - class="gl-bg-white" - event="NONE" - filename="design-2-name" - id="design-2" - image="design-2-image" - notescount="1" - /> - </design-dropzone-stub> - - <input - class="design-checkbox" - data-qa-design="design-2-name" - data-qa-selector="design_checkbox" - type="checkbox" - /> - </li> - <li - class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" - > - <design-dropzone-stub - hasdesigns="true" - > - <design-stub - class="gl-bg-white" - event="NONE" - filename="design-3-name" - id="design-3" - image="design-3-image" - notescount="0" - /> - </design-dropzone-stub> - - <input - class="design-checkbox" - data-qa-design="design-3-name" - data-qa-selector="design_checkbox" - type="checkbox" - /> - </li> - </ol> - </div> - - <router-view-stub - name="default" - /> -</div> -`; - exports[`Design management index page designs renders error 1`] = ` <div class="gl-mt-5" @@ -277,7 +42,7 @@ exports[`Design management index page designs renders loading icon 1`] = ` class="gl-mt-6" > <gl-loading-icon-stub - color="orange" + color="dark" label="Loading" size="md" /> @@ -288,34 +53,3 @@ exports[`Design management index page designs renders loading icon 1`] = ` /> </div> `; - -exports[`Design management index page when has no designs renders design dropzone 1`] = ` -<div - class="gl-mt-5" - data-testid="designs-root" -> - <!----> - - <div - class="gl-mt-6" - > - <ol - class="list-unstyled row" - > - <li - class="col-12" - data-testid="design-dropzone-wrapper" - > - <design-dropzone-stub - class="" - data-qa-selector="design_dropzone_content" - /> - </li> - </ol> - </div> - - <router-view-stub - name="default" - /> -</div> -`; diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 3d6c2561ff6..03ae77d4977 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -136,7 +136,7 @@ exports[`Design management design index page sets loading state 1`] = ` > <gl-loading-icon-stub class="gl-align-self-center" - color="orange" + color="dark" label="Loading" size="xl" /> diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index d9f7146d258..88892bb1878 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -2,7 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueRouter from 'vue-router'; import { GlAlert } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import DesignIndex from '~/design_management/pages/design/index.vue'; import DesignSidebar from '~/design_management/components/design_sidebar.vue'; import DesignPresentation from '~/design_management/components/design_presentation.vue'; @@ -24,7 +24,13 @@ import mockAllVersions from '../../mock_data/all_versions'; jest.mock('~/flash'); const focusInput = jest.fn(); - +const mutate = jest.fn().mockResolvedValue(); +const mockPageLayoutElement = { + classList: { + add: jest.fn(), + remove: jest.fn(), + }, +}; const DesignReplyForm = { template: '<div><textarea ref="textarea"></textarea></div>', methods: { @@ -37,6 +43,32 @@ const mockDesignNoDiscussions = { nodes: [], }, }; +const newComment = 'new comment'; +const annotationCoordinates = { + x: 10, + y: 10, + width: 100, + height: 100, +}; +const createDiscussionMutationVariables = { + mutation: createImageDiffNoteMutation, + update: expect.anything(), + variables: { + input: { + body: newComment, + noteableId: design.id, + position: { + headSha: 'headSha', + baseSha: 'baseSha', + startSha: 'startSha', + paths: { + newPath: 'full-design-path', + }, + ...annotationCoordinates, + }, + }, + }, +}; const localVue = createLocalVue(); localVue.use(VueRouter); @@ -45,35 +77,6 @@ describe('Design management design index page', () => { let wrapper; let router; - const newComment = 'new comment'; - const annotationCoordinates = { - x: 10, - y: 10, - width: 100, - height: 100, - }; - const createDiscussionMutationVariables = { - mutation: createImageDiffNoteMutation, - update: expect.anything(), - variables: { - input: { - body: newComment, - noteableId: design.id, - position: { - headSha: 'headSha', - baseSha: 'baseSha', - startSha: 'startSha', - paths: { - newPath: 'full-design-path', - }, - ...annotationCoordinates, - }, - }, - }, - }; - - const mutate = jest.fn().mockResolvedValue(); - const findDiscussionForm = () => wrapper.find(DesignReplyForm); const findSidebar = () => wrapper.find(DesignSidebar); const findDesignPresentation = () => wrapper.find(DesignPresentation); @@ -122,19 +125,44 @@ describe('Design management design index page', () => { wrapper.destroy(); }); - describe('when navigating', () => { - it('applies fullscreen layout', () => { - const mockEl = { - classList: { - add: jest.fn(), - remove: jest.fn(), - }, - }; - jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockEl); + describe('when navigating to component', () => { + it('applies fullscreen layout class', () => { + jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement); createComponent({ loading: true }); - expect(mockEl.classList.add).toHaveBeenCalledTimes(1); - expect(mockEl.classList.add).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + expect(mockPageLayoutElement.classList.add).toHaveBeenCalledTimes(1); + expect(mockPageLayoutElement.classList.add).toHaveBeenCalledWith( + ...DESIGN_DETAIL_LAYOUT_CLASSLIST, + ); + }); + }); + + describe('when navigating within the component', () => { + it('`scale` prop of DesignPresentation component is 1', async () => { + jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement); + createComponent({ loading: false }, { data: { design, scale: 2 } }); + + await wrapper.vm.$nextTick(); + expect(findDesignPresentation().props('scale')).toBe(2); + + DesignIndex.beforeRouteUpdate.call(wrapper.vm, {}, {}, jest.fn()); + await wrapper.vm.$nextTick(); + + expect(findDesignPresentation().props('scale')).toBe(1); + }); + }); + + describe('when navigating away from component', () => { + it('removes fullscreen layout class', async () => { + jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement); + createComponent({ loading: true }); + + wrapper.vm.$options.beforeRouteLeave[0].call(wrapper.vm, {}, {}, jest.fn()); + + expect(mockPageLayoutElement.classList.remove).toHaveBeenCalledTimes(1); + expect(mockPageLayoutElement.classList.remove).toHaveBeenCalledWith( + ...DESIGN_DETAIL_LAYOUT_CLASSLIST, + ); }); }); @@ -267,7 +295,7 @@ describe('Design management design index page', () => { wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false }); return wrapper.vm.$nextTick().then(() => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR); + expect(createFlash).toHaveBeenCalledWith({ message: DESIGN_NOT_FOUND_ERROR }); expect(router.push).toHaveBeenCalledTimes(1); expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); }); @@ -288,7 +316,7 @@ describe('Design management design index page', () => { wrapper.vm.onDesignQueryResult({ data: mockResponseWithDesigns, loading: false }); return wrapper.vm.$nextTick().then(() => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(DESIGN_VERSION_NOT_EXIST_ERROR); + expect(createFlash).toHaveBeenCalledWith({ message: DESIGN_VERSION_NOT_EXIST_ERROR }); expect(router.push).toHaveBeenCalledTimes(1); expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 27a91b11448..05238efd761 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -5,10 +5,12 @@ import VueRouter from 'vue-router'; import { GlEmptyState } from '@gitlab/ui'; import createMockApollo from 'jest/helpers/mock_apollo_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; +import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql'; import Index from '~/design_management/pages/index.vue'; import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql'; import DesignDestroyer from '~/design_management/components/design_destroyer.vue'; -import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue'; +import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; import DeleteButton from '~/design_management/components/delete_button.vue'; import Design from '~/design_management/components/list/item.vue'; import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; @@ -16,10 +18,9 @@ import { EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, } from '~/design_management/utils/error_messages'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import createRouter from '~/design_management/router'; import * as utils from '~/design_management/utils/design_management_utils'; -import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants'; import { designListQueryResponse, designUploadMutationCreatedResponse, @@ -29,8 +30,6 @@ import { reorderedDesigns, moveDesignMutationResponseWithErrors, } from '../mock_data/apollo_mock'; -import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql'; -import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql'; import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql'; import { DESIGN_TRACKING_PAGE_NAME } from '~/design_management/utils/tracking'; @@ -106,6 +105,8 @@ describe('Design management index page', () => { const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]'); const findDesigns = () => wrapper.findAll(Design); const draggableAttributes = () => wrapper.find(VueDraggable).vm.$attrs; + const findDesignUploadButton = () => wrapper.find('[data-testid="design-upload-button"]'); + const findDesignToolbarWrapper = () => wrapper.find('[data-testid="design-toolbar-wrapper"]'); async function moveDesigns(localWrapper) { await jest.runOnlyPendingTimers(); @@ -215,13 +216,17 @@ describe('Design management index page', () => { it('renders designs list and header with upload button', () => { createComponent({ allVersions: [mockVersion] }); - expect(wrapper.element).toMatchSnapshot(); + expect(findDesignsWrapper().exists()).toBe(true); + expect(findDesigns().length).toBe(3); + expect(findDesignToolbarWrapper().exists()).toBe(true); + expect(findDesignUploadButton().exists()).toBe(true); }); it('does not render toolbar when there is no permission', () => { createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false }); - expect(wrapper.element).toMatchSnapshot(); + expect(findDesignToolbarWrapper().exists()).toBe(false); + expect(findDesignUploadButton().exists()).toBe(false); }); it('has correct classes applied to design dropzone', () => { @@ -248,7 +253,7 @@ describe('Design management index page', () => { it('renders design dropzone', () => wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); + expect(findDropzone().exists()).toBe(true); })); it('has correct classes applied to design dropzone', () => { @@ -444,10 +449,10 @@ describe('Design management index page', () => { return uploadDesign.then(() => { expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Upload skipped. test.jpg did not change.', - 'warning', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Upload skipped. test.jpg did not change.', + types: 'warning', + }); }); }); @@ -483,7 +488,7 @@ describe('Design management index page', () => { designDropzone.vm.$emit('change', eventPayload); expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(message); + expect(createFlash).toHaveBeenCalledWith({ message }); }); }); @@ -682,13 +687,6 @@ describe('Design management index page', () => { }); describe('when navigating', () => { - it('ensures fullscreen layout is not applied', () => { - createComponent({ loading: true }); - - expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1); - expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); - }); - it('should trigger a scrollIntoView method if designs route is detected', () => { router.replace({ path: '/designs', @@ -755,7 +753,7 @@ describe('Design management index page', () => { await wrapper.vm.$nextTick(); - expect(createFlash).toHaveBeenCalledWith('Houston, we have a problem'); + expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' }); }); it('displays flash if mutation had a non-recoverable error', async () => { @@ -769,9 +767,9 @@ describe('Design management index page', () => { await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises) await wrapper.vm.$nextTick(); // kick off the DOM update for flash - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong when reordering designs. Please try again', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong when reordering designs. Please try again', + }); }); }); }); diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js index 6c859e8c3e8..2fb08c3ef05 100644 --- a/spec/frontend/design_management/utils/cache_update_spec.js +++ b/spec/frontend/design_management/utils/cache_update_spec.js @@ -3,7 +3,7 @@ import { updateStoreAfterDesignsDelete, updateStoreAfterAddImageDiffNote, updateStoreAfterUploadDesign, - updateStoreAfterUpdateImageDiffNote, + updateStoreAfterRepositionImageDiffNote, } from '~/design_management/utils/cache_update'; import { designDeletionError, @@ -11,7 +11,7 @@ import { UPDATE_IMAGE_DIFF_NOTE_ERROR, } from '~/design_management/utils/error_messages'; import design from '../mock_data/design'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; jest.mock('~/flash.js'); @@ -26,16 +26,16 @@ describe('Design Management cache update', () => { describe('error handling', () => { it.each` - fnName | subject | errorMessage | extraArgs - ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]} - ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} - ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} - ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} + fnName | subject | errorMessage | extraArgs + ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]} + ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} + ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} + ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterRepositionImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => { expect(createFlash).not.toHaveBeenCalled(); expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow(); expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(errorMessage); + expect(createFlash).toHaveBeenCalledWith({ message: errorMessage }); }); }); }); diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js index 232cfa2f4ca..368448ead10 100644 --- a/spec/frontend/design_management/utils/design_management_utils_spec.js +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -3,7 +3,7 @@ import { extractDiscussions, findVersionId, designUploadOptimisticResponse, - updateImageDiffNoteOptimisticResponse, + repositionImageDiffNoteOptimisticResponse, isValidDesignFile, extractDesign, extractDesignNoteId, @@ -112,7 +112,7 @@ describe('optimistic responses', () => { expect(designUploadOptimisticResponse([{ name: 'test' }])).toEqual(expectedResponse); }); - it('correctly generated for updateImageDiffNoteOptimisticResponse', () => { + it('correctly generated for repositionImageDiffNoteOptimisticResponse', () => { const mockNote = { id: 'test-note-id', }; @@ -126,8 +126,8 @@ describe('optimistic responses', () => { const expectedResponse = { __typename: 'Mutation', - updateImageDiffNote: { - __typename: 'UpdateImageDiffNotePayload', + repositionImageDiffNote: { + __typename: 'RepositionImageDiffNotePayload', note: { ...mockNote, position: mockPosition, @@ -135,7 +135,7 @@ describe('optimistic responses', () => { errors: [], }, }; - expect(updateImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual( + expect(repositionImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual( expectedResponse, ); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 86560470ada..225710eab63 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -697,7 +697,7 @@ describe('diffs/components/app', () => { }); describe('collapsed files', () => { - it('should render the collapsed files warning if there are any collapsed files', () => { + it('should render the collapsed files warning if there are any automatically collapsed files', () => { createComponent({}, ({ state }) => { state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }]; }); @@ -705,16 +705,14 @@ describe('diffs/components/app', () => { expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true); }); - it('should not render the collapsed files warning if the user has dismissed the alert already', async () => { + it('should not render the collapsed files warning if there are no automatically collapsed files', () => { createComponent({}, ({ state }) => { - state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }]; + state.diffs.diffFiles = [ + { viewer: { automaticallyCollapsed: false, manuallyCollapsed: true } }, + { viewer: { automaticallyCollapsed: false, manuallyCollapsed: false } }, + ]; }); - expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true); - - wrapper.vm.collapsedWarningDismissed = true; - await wrapper.vm.$nextTick(); - expect(getCollapsedFilesWarning(wrapper).exists()).toBe(false); }); }); diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js index 7bbffb7a1cd..75e76d88b6b 100644 --- a/spec/frontend/diffs/components/collapsed_files_warning_spec.js +++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js @@ -2,7 +2,8 @@ import Vuex from 'vuex'; import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; import createStore from '~/diffs/store/modules'; import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; -import { CENTERED_LIMITED_CONTAINER_CLASSES } from '~/diffs/constants'; +import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '~/diffs/constants'; +import eventHub from '~/diffs/event_hub'; const propsData = { limited: true, @@ -76,13 +77,13 @@ describe('CollapsedFilesWarning', () => { expect(wrapper.find('[data-testid="root"]').exists()).toBe(false); }); - it('triggers the expandAllFiles action when the alert action button is clicked', () => { + it(`emits the \`${EVT_EXPAND_ALL_FILES}\` event when the alert action button is clicked`, () => { createComponent({}, { full: true }); - jest.spyOn(wrapper.vm.$store, 'dispatch').mockReturnValue(undefined); + jest.spyOn(eventHub, '$emit'); getAlertActionButton().vm.$emit('click'); - expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/expandAllFiles', undefined); + expect(eventHub.$emit).toHaveBeenCalledWith(EVT_EXPAND_ALL_FILES); }); }); diff --git a/spec/frontend/diffs/components/diff_comment_cell_spec.js b/spec/frontend/diffs/components/diff_comment_cell_spec.js new file mode 100644 index 00000000000..d6b68fc52d7 --- /dev/null +++ b/spec/frontend/diffs/components/diff_comment_cell_spec.js @@ -0,0 +1,43 @@ +import { shallowMount } from '@vue/test-utils'; +import DiffCommentCell from '~/diffs/components/diff_comment_cell.vue'; +import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; +import DiffDiscussionReply from '~/diffs/components/diff_discussion_reply.vue'; + +describe('DiffCommentCell', () => { + const createWrapper = (props = {}) => { + const { renderDiscussion, ...otherProps } = props; + const line = { + discussions: [], + renderDiscussion, + }; + const diffFileHash = 'abc'; + + return shallowMount(DiffCommentCell, { + propsData: { line, diffFileHash, ...otherProps }, + }); + }; + + it('renders discussions if line has discussions', () => { + const wrapper = createWrapper({ renderDiscussion: true }); + + expect(wrapper.find(DiffDiscussions).exists()).toBe(true); + }); + + it('does not render discussions if line has no discussions', () => { + const wrapper = createWrapper(); + + expect(wrapper.find(DiffDiscussions).exists()).toBe(false); + }); + + it('renders discussion reply if line has no draft', () => { + const wrapper = createWrapper(); + + expect(wrapper.find(DiffDiscussionReply).exists()).toBe(true); + }); + + it('does not render discussion reply if line has draft', () => { + const wrapper = createWrapper({ hasDraft: true }); + + expect(wrapper.find(DiffDiscussionReply).exists()).toBe(false); + }); +}); diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index 6d0120d888e..e3a6f7f16a9 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -12,6 +12,8 @@ import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import diffFileMockData from '../mock_data/diff_file'; import { diffViewerModes } from '~/ide/constants'; +import { diffLines } from '~/diffs/store/getters'; +import DiffView from '~/diffs/components/diff_view.vue'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -33,7 +35,7 @@ describe('DiffContent', () => { diffFile: JSON.parse(JSON.stringify(diffFileMockData)), }; - const createComponent = ({ props, state } = {}) => { + const createComponent = ({ props, state, provide } = {}) => { const fakeStore = new Vuex.Store({ getters: { getNoteableData() { @@ -55,6 +57,10 @@ describe('DiffContent', () => { namespaced: true, getters: { draftsForFile: () => () => true, + draftForLine: () => () => true, + shouldRenderDraftRow: () => () => true, + hasParallelDraftLeft: () => () => true, + hasParallelDraftRight: () => () => true, }, }, diffs: { @@ -68,6 +74,7 @@ describe('DiffContent', () => { isInlineView: isInlineViewGetterMock, isParallelView: isParallelViewGetterMock, getCommentFormForDiffFile: getCommentFormForDiffFileGetterMock, + diffLines, }, actions: { saveDiffDiscussion: saveDiffDiscussionMock, @@ -77,6 +84,8 @@ describe('DiffContent', () => { }, }); + const glFeatures = provide ? { ...provide.glFeatures } : {}; + wrapper = shallowMount(DiffContentComponent, { propsData: { ...defaultProps, @@ -84,6 +93,7 @@ describe('DiffContent', () => { }, localVue, store: fakeStore, + provide: { glFeatures }, }); }; @@ -112,6 +122,16 @@ describe('DiffContent', () => { expect(wrapper.find(ParallelDiffView).exists()).toBe(true); }); + it('should render diff view if `unifiedDiffLines` & `unifiedDiffComponents` are true', () => { + isParallelViewGetterMock.mockReturnValue(true); + createComponent({ + props: { diffFile: textDiffFile }, + provide: { glFeatures: { unifiedDiffLines: true, unifiedDiffComponents: true } }, + }); + + expect(wrapper.find(DiffView).exists()).toBe(true); + }); + it('renders rendering more lines loading icon', () => { createComponent({ props: { diffFile: { ...textDiffFile, renderingLines: true } } }); diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index a04486fc5c7..1b41456f2f5 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -1,8 +1,12 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { cloneDeep } from 'lodash'; + +import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; + import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; import diffDiscussionsMockData from '../mock_data/diff_discussions'; import { truncateSha } from '~/lib/utils/text_utility'; import { diffViewerModes } from '~/ide/constants'; @@ -136,9 +140,25 @@ describe('DiffFileHeader component', () => { }); }); - it('displays a copy to clipboard button', () => { - createComponent(); - expect(wrapper.find(ClipboardButton).exists()).toBe(true); + describe('copy to clipboard', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays a copy to clipboard button', () => { + expect(wrapper.find(ClipboardButton).exists()).toBe(true); + }); + + it('triggers the copy to clipboard tracking event', () => { + const trackingSpy = mockTracking('_category_', wrapper.vm.$el, jest.spyOn); + + triggerEvent('[data-testid="diff-file-copy-clipboard"]'); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_copy_file_button', { + label: 'diff_copy_file_path_button', + property: 'diff_copy_file', + }); + }); }); describe('for submodule', () => { @@ -188,6 +208,14 @@ describe('DiffFileHeader component', () => { }); expect(findFileActions().exists()).toBe(false); }); + + it('renders submodule icon', () => { + createComponent({ + diffFile: submoduleDiffFile, + }); + + expect(wrapper.find(FileIcon).props('submodule')).toBe(true); + }); }); describe('for any file', () => { diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index a6f0d2bf11d..71e0ffd176f 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -1,262 +1,422 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; -import { createStore } from '~/mr_notes/stores'; -import DiffFileComponent from '~/diffs/components/diff_file.vue'; -import { diffViewerModes, diffViewerErrors } from '~/ide/constants'; +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; + +import createDiffsStore from '~/diffs/store/modules'; +import createNotesStore from '~/notes/stores/modules'; import diffFileMockDataReadable from '../mock_data/diff_file'; import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable'; -describe('DiffFile', () => { - let vm; - let trackingSpy; +import DiffFileComponent from '~/diffs/components/diff_file.vue'; +import DiffFileHeaderComponent from '~/diffs/components/diff_file_header.vue'; +import DiffContentComponent from '~/diffs/components/diff_content.vue'; - beforeEach(() => { - vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { - file: JSON.parse(JSON.stringify(diffFileMockDataReadable)), +import eventHub from '~/diffs/event_hub'; +import { + EVT_EXPAND_ALL_FILES, + EVT_PERF_MARK_DIFF_FILES_END, + EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, +} from '~/diffs/constants'; + +import { diffViewerModes, diffViewerErrors } from '~/ide/constants'; + +function changeViewer(store, index, { automaticallyCollapsed, manuallyCollapsed, name }) { + const file = store.state.diffs.diffFiles[index]; + const newViewer = { + ...file.viewer, + }; + + if (automaticallyCollapsed !== undefined) { + newViewer.automaticallyCollapsed = automaticallyCollapsed; + } + + if (manuallyCollapsed !== undefined) { + newViewer.manuallyCollapsed = manuallyCollapsed; + } + + if (name !== undefined) { + newViewer.name = name; + } + + Object.assign(file, { + viewer: newViewer, + }); +} + +function forceHasDiff({ store, index = 0, inlineLines, parallelLines, readableText }) { + const file = store.state.diffs.diffFiles[index]; + + Object.assign(file, { + highlighted_diff_lines: inlineLines, + parallel_diff_lines: parallelLines, + blob: { + ...file.blob, + readable_text: readableText, + }, + }); +} + +function markFileToBeRendered(store, index = 0) { + const file = store.state.diffs.diffFiles[index]; + + Object.assign(file, { + renderIt: true, + }); +} + +function createComponent({ file, first = false, last = false }) { + const localVue = createLocalVue(); + + localVue.use(Vuex); + + const store = new Vuex.Store({ + ...createNotesStore(), + modules: { + diffs: createDiffsStore(), + }, + }); + + store.state.diffs.diffFiles = [file]; + + const wrapper = shallowMount(DiffFileComponent, { + store, + localVue, + propsData: { + file, canCurrentUserFork: false, viewDiffsFileByFile: false, - }).$mount(); - trackingSpy = mockTracking('_category_', vm.$el, jest.spyOn); + isFirstFile: first, + isLastFile: last, + }, + }); + + return { + localVue, + wrapper, + store, + }; +} + +const findDiffHeader = wrapper => wrapper.find(DiffFileHeaderComponent); +const findDiffContentArea = wrapper => wrapper.find('[data-testid="content-area"]'); +const findLoader = wrapper => wrapper.find('[data-testid="loader-icon"]'); +const findToggleButton = wrapper => wrapper.find('[data-testid="expand-button"]'); + +const toggleFile = wrapper => findDiffHeader(wrapper).vm.$emit('toggleFile'); +const isDisplayNone = element => element.style.display === 'none'; +const getReadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataReadable)); +const getUnreadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataUnreadable)); + +const makeFileAutomaticallyCollapsed = (store, index = 0) => + changeViewer(store, index, { automaticallyCollapsed: true, manuallyCollapsed: null }); +const makeFileOpenByDefault = (store, index = 0) => + changeViewer(store, index, { automaticallyCollapsed: false, manuallyCollapsed: null }); +const makeFileManuallyCollapsed = (store, index = 0) => + changeViewer(store, index, { automaticallyCollapsed: false, manuallyCollapsed: true }); +const changeViewerType = (store, newType, index = 0) => + changeViewer(store, index, { name: diffViewerModes[newType] }); + +describe('DiffFile', () => { + let wrapper; + let store; + + beforeEach(() => { + ({ wrapper, store } = createComponent({ file: getReadableFile() })); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); - const findDiffContent = () => vm.$el.querySelector('.diff-content'); - const isVisible = el => el.style.display !== 'none'; + describe('bus events', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + describe('during mount', () => { + it.each` + first | last | events | file + ${false} | ${false} | ${[]} | ${{ inlineLines: [], parallelLines: [], readableText: true }} + ${true} | ${true} | ${[]} | ${{ inlineLines: [], parallelLines: [], readableText: true }} + ${true} | ${false} | ${[EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN]} | ${false} + ${false} | ${true} | ${[EVT_PERF_MARK_DIFF_FILES_END]} | ${false} + ${true} | ${true} | ${[EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, EVT_PERF_MARK_DIFF_FILES_END]} | ${false} + `( + 'emits the events $events based on the file and its position ({ first: $first, last: $last }) among all files', + async ({ file, first, last, events }) => { + if (file) { + forceHasDiff({ store, ...file }); + } + + ({ wrapper, store } = createComponent({ + file: store.state.diffs.diffFiles[0], + first, + last, + })); + + await wrapper.vm.$nextTick(); + + expect(eventHub.$emit).toHaveBeenCalledTimes(events.length); + events.forEach(event => { + expect(eventHub.$emit).toHaveBeenCalledWith(event); + }); + }, + ); + }); + + describe('after loading the diff', () => { + it('indicates that it loaded the file', async () => { + forceHasDiff({ store, inlineLines: [], parallelLines: [], readableText: true }); + ({ wrapper, store } = createComponent({ + file: store.state.diffs.diffFiles[0], + first: true, + last: true, + })); + + jest.spyOn(wrapper.vm, 'loadCollapsedDiff').mockResolvedValue(getReadableFile()); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(fn => fn()); + + makeFileAutomaticallyCollapsed(store); + + await wrapper.vm.$nextTick(); // Wait for store updates to flow into the component + + toggleFile(wrapper); + + await wrapper.vm.$nextTick(); // Wait for the load to resolve + await wrapper.vm.$nextTick(); // Wait for the idleCallback + await wrapper.vm.$nextTick(); // Wait for nextTick inside postRender + + expect(eventHub.$emit).toHaveBeenCalledTimes(2); + expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN); + expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_DIFF_FILES_END); + }); + }); + }); describe('template', () => { - it('should render component with file header, file content components', done => { - const el = vm.$el; - const { file_hash, file_path } = vm.file; + it('should render component with file header, file content components', async () => { + const el = wrapper.vm.$el; + const { file_hash } = wrapper.vm.file; expect(el.id).toEqual(file_hash); expect(el.classList.contains('diff-file')).toEqual(true); expect(el.querySelectorAll('.diff-content.hidden').length).toEqual(0); expect(el.querySelector('.js-file-title')).toBeDefined(); - expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined(); - expect(el.querySelector('.file-title-name').innerText.indexOf(file_path)).toBeGreaterThan(-1); + expect(wrapper.find(DiffFileHeaderComponent).exists()).toBe(true); expect(el.querySelector('.js-syntax-highlight')).toBeDefined(); - vm.file.renderIt = true; + markFileToBeRendered(store); + + await wrapper.vm.$nextTick(); - vm.$nextTick() - .then(() => { - expect(el.querySelectorAll('.line_content').length).toBe(8); - expect(el.querySelectorAll('.js-line-expansion-content').length).toBe(1); - triggerEvent('[data-testid="diff-file-copy-clipboard"]'); - }) - .then(done) - .catch(done.fail); + expect(wrapper.find(DiffContentComponent).exists()).toBe(true); }); + }); - it('should track a click event on copy to clip board button', done => { - const el = vm.$el; + describe('collapsing', () => { + describe(`\`${EVT_EXPAND_ALL_FILES}\` event`, () => { + beforeEach(() => { + jest.spyOn(wrapper.vm, 'handleToggle').mockImplementation(() => {}); + }); - expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined(); - vm.file.renderIt = true; - vm.$nextTick() - .then(() => { - triggerEvent('[data-testid="diff-file-copy-clipboard"]'); + it('performs the normal file toggle when the file is collapsed', async () => { + makeFileAutomaticallyCollapsed(store); - expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_copy_file_button', { - label: 'diff_copy_file_path_button', - property: 'diff_copy_file', - }); - }) - .then(done) - .catch(done.fail); - }); + await wrapper.vm.$nextTick(); - describe('collapsed', () => { - it('should not have file content', done => { - expect(isVisible(findDiffContent())).toBe(true); - expect(vm.isCollapsed).toEqual(false); - vm.isCollapsed = true; - vm.file.renderIt = true; + eventHub.$emit(EVT_EXPAND_ALL_FILES); - vm.$nextTick(() => { - expect(isVisible(findDiffContent())).toBe(false); - - done(); - }); + expect(wrapper.vm.handleToggle).toHaveBeenCalledTimes(1); }); - it('should have collapsed text and link', done => { - vm.renderIt = true; - vm.isCollapsed = true; + it('does nothing when the file is not collapsed', async () => { + eventHub.$emit(EVT_EXPAND_ALL_FILES); - vm.$nextTick(() => { - expect(vm.$el.innerText).toContain('This diff is collapsed'); - expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); + await wrapper.vm.$nextTick(); - done(); - }); + expect(wrapper.vm.handleToggle).not.toHaveBeenCalled(); }); + }); - it('should have collapsed text and link even before rendered', done => { - vm.renderIt = false; - vm.isCollapsed = true; + describe('user collapsed', () => { + beforeEach(() => { + makeFileManuallyCollapsed(store); + }); - vm.$nextTick(() => { - expect(vm.$el.innerText).toContain('This diff is collapsed'); - expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); + it('should not have any content at all', async () => { + await wrapper.vm.$nextTick(); - done(); + Array.from(findDiffContentArea(wrapper).element.children).forEach(child => { + expect(isDisplayNone(child)).toBe(true); }); }); - it('should be collapsable for unreadable files', done => { - vm.$destroy(); - vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { - file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)), - canCurrentUserFork: false, - viewDiffsFileByFile: false, - }).$mount(); + it('should not have the class `has-body` to present the header differently', () => { + expect(wrapper.classes('has-body')).toBe(false); + }); + }); - vm.renderIt = false; - vm.isCollapsed = true; + describe('automatically collapsed', () => { + beforeEach(() => { + makeFileAutomaticallyCollapsed(store); + }); - vm.$nextTick(() => { - expect(vm.$el.innerText).toContain('This diff is collapsed'); - expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); + it('should show the collapsed file warning with expansion button', () => { + expect(findDiffContentArea(wrapper).html()).toContain( + 'Files with large changes are collapsed by default.', + ); + expect(findToggleButton(wrapper).exists()).toBe(true); + }); - done(); - }); + it('should style the component so that it `.has-body` for layout purposes', () => { + expect(wrapper.classes('has-body')).toBe(true); }); + }); - it('should be collapsed for renamed files', done => { - vm.renderIt = true; - vm.isCollapsed = false; - vm.file.highlighted_diff_lines = null; - vm.file.viewer.name = diffViewerModes.renamed; + describe('not collapsed', () => { + beforeEach(() => { + makeFileOpenByDefault(store); + markFileToBeRendered(store); + }); - vm.$nextTick(() => { - expect(vm.$el.innerText).not.toContain('This diff is collapsed'); + it('should have the file content', async () => { + expect(wrapper.find(DiffContentComponent).exists()).toBe(true); + }); - done(); - }); + it('should style the component so that it `.has-body` for layout purposes', () => { + expect(wrapper.classes('has-body')).toBe(true); }); + }); - it('should be collapsed for mode changed files', done => { - vm.renderIt = true; - vm.isCollapsed = false; - vm.file.highlighted_diff_lines = null; - vm.file.viewer.name = diffViewerModes.mode_changed; + describe('toggle', () => { + it('should update store state', async () => { + jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(() => {}); - vm.$nextTick(() => { - expect(vm.$el.innerText).not.toContain('This diff is collapsed'); + toggleFile(wrapper); - done(); + expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/setFileCollapsedByUser', { + filePath: wrapper.vm.file.file_path, + collapsed: true, }); }); - it('should have loading icon while loading a collapsed diffs', done => { - vm.isCollapsed = true; - vm.isLoadingCollapsedDiff = true; + describe('fetch collapsed diff', () => { + const prepFile = async (inlineLines, parallelLines, readableText) => { + forceHasDiff({ + store, + inlineLines, + parallelLines, + readableText, + }); + + await wrapper.vm.$nextTick(); - vm.$nextTick(() => { - expect(vm.$el.querySelectorAll('.diff-content.loading').length).toEqual(1); + toggleFile(wrapper); + }; - done(); + beforeEach(() => { + jest.spyOn(wrapper.vm, 'requestDiff').mockImplementation(() => {}); + + makeFileAutomaticallyCollapsed(store); }); - }); - it('should update store state', done => { - jest.spyOn(vm.$store, 'dispatch').mockImplementation(() => {}); + it.each` + inlineLines | parallelLines | readableText + ${[1]} | ${[1]} | ${true} + ${[]} | ${[1]} | ${true} + ${[1]} | ${[]} | ${true} + ${[1]} | ${[1]} | ${false} + ${[]} | ${[]} | ${false} + `( + 'does not make a request to fetch the diff for a diff file like { inline: $inlineLines, parallel: $parallelLines, readableText: $readableText }', + async ({ inlineLines, parallelLines, readableText }) => { + await prepFile(inlineLines, parallelLines, readableText); + + expect(wrapper.vm.requestDiff).not.toHaveBeenCalled(); + }, + ); - vm.isCollapsed = true; + it.each` + inlineLines | parallelLines | readableText + ${[]} | ${[]} | ${true} + `( + 'makes a request to fetch the diff for a diff file like { inline: $inlineLines, parallel: $parallelLines, readableText: $readableText }', + async ({ inlineLines, parallelLines, readableText }) => { + await prepFile(inlineLines, parallelLines, readableText); - vm.$nextTick(() => { - expect(vm.$store.dispatch).toHaveBeenCalledWith('diffs/setFileCollapsed', { - filePath: vm.file.file_path, - collapsed: true, - }); + expect(wrapper.vm.requestDiff).toHaveBeenCalled(); + }, + ); + }); + }); - done(); - }); + describe('loading', () => { + it('should have loading icon while loading a collapsed diffs', async () => { + makeFileAutomaticallyCollapsed(store); + wrapper.vm.isLoadingCollapsedDiff = true; + + await wrapper.vm.$nextTick(); + + expect(findLoader(wrapper).exists()).toBe(true); }); + }); - it('updates local state when changing file state', done => { - vm.file.viewer.automaticallyCollapsed = true; + describe('general (other) collapsed', () => { + it('should be expandable for unreadable files', async () => { + ({ wrapper, store } = createComponent({ file: getUnreadableFile() })); + makeFileAutomaticallyCollapsed(store); - vm.$nextTick(() => { - expect(vm.isCollapsed).toBe(true); + await wrapper.vm.$nextTick(); - done(); - }); + expect(findDiffContentArea(wrapper).html()).toContain( + 'Files with large changes are collapsed by default.', + ); + expect(findToggleButton(wrapper).exists()).toBe(true); }); + + it.each` + mode + ${'renamed'} + ${'mode_changed'} + `( + 'should render the DiffContent component for files whose mode is $mode', + async ({ mode }) => { + makeFileOpenByDefault(store); + markFileToBeRendered(store); + changeViewerType(store, mode); + + await wrapper.vm.$nextTick(); + + expect(wrapper.classes('has-body')).toBe(true); + expect(wrapper.find(DiffContentComponent).exists()).toBe(true); + expect(wrapper.find(DiffContentComponent).isVisible()).toBe(true); + }, + ); }); }); describe('too large diff', () => { - it('should have too large warning and blob link', done => { + it('should have too large warning and blob link', async () => { + const file = store.state.diffs.diffFiles[0]; const BLOB_LINK = '/file/view/path'; - vm.file.viewer.error = diffViewerErrors.too_large; - vm.file.viewer.error_message = - 'This source diff could not be displayed because it is too large'; - vm.file.view_path = BLOB_LINK; - vm.file.renderIt = true; - - vm.$nextTick(() => { - expect(vm.$el.innerText).toContain( - 'This source diff could not be displayed because it is too large', - ); - done(); + Object.assign(store.state.diffs.diffFiles[0], { + ...file, + view_path: BLOB_LINK, + renderIt: true, + viewer: { + ...file.viewer, + error: diffViewerErrors.too_large, + error_message: 'This source diff could not be displayed because it is too large', + }, }); - }); - }); - describe('watch collapsed', () => { - it('calls handleLoadCollapsedDiff if collapsed changed & file has no lines', done => { - jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {}); - - vm.file.highlighted_diff_lines = []; - vm.file.parallel_diff_lines = []; - vm.isCollapsed = true; - - vm.$nextTick() - .then(() => { - vm.isCollapsed = false; - - return vm.$nextTick(); - }) - .then(() => { - expect(vm.handleLoadCollapsedDiff).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); + await wrapper.vm.$nextTick(); - it('does not call handleLoadCollapsedDiff if collapsed changed & file is unreadable', done => { - vm.$destroy(); - vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { - file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)), - canCurrentUserFork: false, - viewDiffsFileByFile: false, - }).$mount(); - - jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {}); - - vm.file.highlighted_diff_lines = []; - vm.file.parallel_diff_lines = undefined; - vm.isCollapsed = true; - - vm.$nextTick() - .then(() => { - vm.isCollapsed = false; - - return vm.$nextTick(); - }) - .then(() => { - expect(vm.handleLoadCollapsedDiff).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + expect(wrapper.vm.$el.innerText).toContain( + 'This source diff could not be displayed because it is too large', + ); }); }); }); diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js new file mode 100644 index 00000000000..f9e76cf8107 --- /dev/null +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -0,0 +1,127 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import diffsModule from '~/diffs/store/modules'; +import DiffRow from '~/diffs/components/diff_row.vue'; + +describe('DiffRow', () => { + const testLines = [ + { + left: { old_line: 1, discussions: [] }, + right: { new_line: 1, discussions: [] }, + hasDiscussionsLeft: true, + hasDiscussionsRight: true, + }, + { + left: {}, + right: {}, + isMatchLineLeft: true, + isMatchLineRight: true, + }, + {}, + { + left: { old_line: 1, discussions: [] }, + right: { new_line: 1, discussions: [] }, + }, + ]; + + const createWrapper = ({ props, state, isLoggedIn = true }) => { + const localVue = createLocalVue(); + localVue.use(Vuex); + + const diffs = diffsModule(); + diffs.state = { ...diffs.state, ...state }; + + const getters = { isLoggedIn: () => isLoggedIn }; + + const store = new Vuex.Store({ + modules: { diffs }, + getters, + }); + + const propsData = { + fileHash: 'abc', + filePath: 'abc', + line: {}, + ...props, + }; + return shallowMount(DiffRow, { propsData, localVue, store }); + }; + + it('isHighlighted returns true if isCommented is true', () => { + const props = { isCommented: true }; + const wrapper = createWrapper({ props }); + expect(wrapper.vm.isHighlighted).toBe(true); + }); + + it('isHighlighted returns true given line.left', () => { + const props = { + line: { + left: { + line_code: 'abc', + }, + }, + }; + const state = { highlightedRow: 'abc' }; + const wrapper = createWrapper({ props, state }); + expect(wrapper.vm.isHighlighted).toBe(true); + }); + + it('isHighlighted returns true given line.right', () => { + const props = { + line: { + right: { + line_code: 'abc', + }, + }, + }; + const state = { highlightedRow: 'abc' }; + const wrapper = createWrapper({ props, state }); + expect(wrapper.vm.isHighlighted).toBe(true); + }); + + it('isHighlighted returns false given line.left', () => { + const props = { + line: { + left: { + line_code: 'abc', + }, + }, + }; + const wrapper = createWrapper({ props }); + expect(wrapper.vm.isHighlighted).toBe(false); + }); + + describe.each` + side + ${'left'} + ${'right'} + `('$side side', ({ side }) => { + it(`renders empty cells if ${side} is unavailable`, () => { + const wrapper = createWrapper({ props: { line: testLines[2] } }); + expect(wrapper.find(`[data-testid="${side}LineNumber"]`).exists()).toBe(false); + expect(wrapper.find(`[data-testid="${side}EmptyCell"]`).exists()).toBe(true); + }); + + it('renders comment button', () => { + const wrapper = createWrapper({ props: { line: testLines[3] } }); + expect(wrapper.find(`[data-testid="${side}CommentButton"]`).exists()).toBe(true); + }); + + it('renders avatars', () => { + const wrapper = createWrapper({ props: { line: testLines[0] } }); + expect(wrapper.find(`[data-testid="${side}Discussions"]`).exists()).toBe(true); + }); + }); + + it('renders left line numbers', () => { + const wrapper = createWrapper({ props: { line: testLines[0] } }); + const lineNumber = testLines[0].left.old_line; + expect(wrapper.find(`[data-linenumber="${lineNumber}"]`).exists()).toBe(true); + }); + + it('renders right line numbers', () => { + const wrapper = createWrapper({ props: { line: testLines[0] } }); + const lineNumber = testLines[0].right.new_line; + expect(wrapper.find(`[data-linenumber="${lineNumber}"]`).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js index 394b6cb1914..c001857fa49 100644 --- a/spec/frontend/diffs/components/diff_row_utils_spec.js +++ b/spec/frontend/diffs/components/diff_row_utils_spec.js @@ -201,3 +201,76 @@ describe('shouldShowCommentButton', () => { }, ); }); + +describe('mapParallel', () => { + it('should assign computed properties to the line object', () => { + const side = { + discussions: [{}], + discussionsExpanded: true, + hasForm: true, + }; + const content = { + diffFile: {}, + hasParallelDraftLeft: () => false, + hasParallelDraftRight: () => false, + draftForLine: () => ({}), + }; + const line = { left: side, right: side }; + const expectation = { + commentRowClasses: '', + draftRowClasses: 'js-temp-notes-holder', + hasDiscussionsLeft: true, + hasDiscussionsRight: true, + isContextLineLeft: false, + isContextLineRight: false, + isMatchLineLeft: false, + isMatchLineRight: false, + isMetaLineLeft: false, + isMetaLineRight: false, + }; + const leftExpectation = { + renderDiscussion: true, + hasDraft: false, + lineDraft: {}, + hasCommentForm: true, + }; + const rightExpectation = { + renderDiscussion: false, + hasDraft: false, + lineDraft: {}, + hasCommentForm: false, + }; + const mapped = utils.mapParallel(content)(line); + + expect(mapped).toMatchObject(expectation); + expect(mapped.left).toMatchObject(leftExpectation); + expect(mapped.right).toMatchObject(rightExpectation); + }); +}); + +describe('mapInline', () => { + it('should assign computed properties to the line object', () => { + const content = { + diffFile: {}, + shouldRenderDraftRow: () => false, + }; + const line = { + discussions: [{}], + discussionsExpanded: true, + hasForm: true, + }; + const expectation = { + commentRowClasses: '', + hasDiscussions: true, + isContextLine: false, + isMatchLine: false, + isMetaLine: false, + renderDiscussion: true, + hasDraft: false, + hasCommentForm: true, + }; + const mapped = utils.mapInline(content)(line); + + expect(mapped).toMatchObject(expectation); + }); +}); diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js new file mode 100644 index 00000000000..4d90112d8f6 --- /dev/null +++ b/spec/frontend/diffs/components/diff_view_spec.js @@ -0,0 +1,82 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import DiffView from '~/diffs/components/diff_view.vue'; +// import DraftNote from '~/batch_comments/components/draft_note.vue'; +// import DiffRow from '~/diffs/components/diff_row.vue'; +// import DiffCommentCell from '~/diffs/components/diff_comment_cell.vue'; +// import DiffExpansionCell from '~/diffs/components/diff_expansion_cell.vue'; + +describe('DiffView', () => { + const DiffExpansionCell = { template: `<div/>` }; + const DiffRow = { template: `<div/>` }; + const DiffCommentCell = { template: `<div/>` }; + const DraftNote = { template: `<div/>` }; + const createWrapper = props => { + const localVue = createLocalVue(); + localVue.use(Vuex); + + const batchComments = { + getters: { + shouldRenderDraftRow: () => false, + shouldRenderParallelDraftRow: () => () => true, + draftForLine: () => false, + draftsForFile: () => false, + hasParallelDraftLeft: () => false, + hasParallelDraftRight: () => false, + }, + namespaced: true, + }; + const diffs = { getters: { commitId: () => 'abc123' }, namespaced: true }; + const notes = { + state: { selectedCommentPosition: null, selectedCommentPositionHover: null }, + }; + + const store = new Vuex.Store({ + modules: { diffs, notes, batchComments }, + }); + + const propsData = { + diffFile: {}, + diffLines: [], + ...props, + }; + const stubs = { DiffExpansionCell, DiffRow, DiffCommentCell, DraftNote }; + return shallowMount(DiffView, { propsData, store, localVue, stubs }); + }; + + it('renders a match line', () => { + const wrapper = createWrapper({ diffLines: [{ isMatchLineLeft: true }] }); + expect(wrapper.find(DiffExpansionCell).exists()).toBe(true); + }); + + it.each` + type | side | container | sides | total + ${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {} }, right: { lineDraft: {} } }} | ${2} + ${'parallel'} | ${'right'} | ${'.new'} | ${{ left: { lineDraft: {} }, right: { lineDraft: {} } }} | ${2} + ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {} } }} | ${1} + ${'inline'} | ${'right'} | ${'.new'} | ${{ right: { lineDraft: {} } }} | ${1} + ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {} }, right: { lineDraft: {} } }} | ${1} + `( + 'renders a $type comment row with comment cell on $side', + ({ type, container, sides, total }) => { + const wrapper = createWrapper({ + diffLines: [{ renderCommentRow: true, ...sides }], + inline: type === 'inline', + }); + expect(wrapper.findAll(DiffCommentCell).length).toBe(total); + expect( + wrapper + .find(container) + .find(DiffCommentCell) + .exists(), + ).toBe(true); + }, + ); + + it('renders a draft row', () => { + const wrapper = createWrapper({ + diffLines: [{ renderCommentRow: true, left: { lineDraft: { isDraft: true } } }], + }); + expect(wrapper.find(DraftNote).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js deleted file mode 100644 index 81e5403d502..00000000000 --- a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { createStore } from '~/mr_notes/stores'; -import InlineDiffExpansionRow from '~/diffs/components/inline_diff_expansion_row.vue'; -import diffFileMockData from '../mock_data/diff_file'; - -describe('InlineDiffExpansionRow', () => { - const mockData = { ...diffFileMockData }; - const matchLine = mockData.highlighted_diff_lines.pop(); - - const createComponent = (options = {}) => { - const cmp = Vue.extend(InlineDiffExpansionRow); - const defaults = { - fileHash: mockData.file_hash, - contextLinesPath: 'contextLinesPath', - line: matchLine, - isTop: false, - isBottom: false, - }; - const props = { ...defaults, ...options }; - - return createComponentWithStore(cmp, createStore(), props).$mount(); - }; - - describe('template', () => { - it('should render expansion row for match lines', () => { - const vm = createComponent(); - - expect(vm.$el.classList.contains('line_expansion')).toBe(true); - }); - }); -}); diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js index c65a39b9083..21e7d7397a0 100644 --- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js @@ -4,6 +4,7 @@ import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue'; import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; import diffFileMockData from '../mock_data/diff_file'; import discussionsMockData from '../mock_data/diff_discussions'; +import { mapInline } from '~/diffs/components/diff_row_utils'; const TEST_USER_ID = 'abc123'; const TEST_USER = { id: TEST_USER_ID }; @@ -11,7 +12,16 @@ const TEST_USER = { id: TEST_USER_ID }; describe('InlineDiffTableRow', () => { let wrapper; let store; - const thisLine = diffFileMockData.highlighted_diff_lines[0]; + const mockDiffContent = { + diffFile: diffFileMockData, + shouldRenderDraftRow: jest.fn(), + hasParallelDraftLeft: jest.fn(), + hasParallelDraftRight: jest.fn(), + draftForLine: jest.fn(), + }; + + const applyMap = mapInline(mockDiffContent); + const thisLine = applyMap(diffFileMockData.highlighted_diff_lines[0]); const createComponent = (props = {}, propsStore = store) => { wrapper = shallowMount(InlineDiffTableRow, { @@ -132,7 +142,7 @@ describe('InlineDiffTableRow', () => { ${true} | ${{ ...thisLine, type: 'old-nonewline', discussions: [] }} | ${false} ${true} | ${{ ...thisLine, discussions: [{}] }} | ${false} `('visible is $expectation - line ($line)', ({ isHover, line, expectation }) => { - createComponent({ line }); + createComponent({ line: applyMap(line) }); wrapper.setData({ isHover }); return wrapper.vm.$nextTick().then(() => { @@ -148,7 +158,7 @@ describe('InlineDiffTableRow', () => { 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled', ({ disabled, commentsDisabled }) => { createComponent({ - line: { ...thisLine, commentsDisabled }, + line: applyMap({ ...thisLine, commentsDisabled }), }); wrapper.setData({ isHover: true }); @@ -177,7 +187,7 @@ describe('InlineDiffTableRow', () => { 'has the correct tooltip when commentsDisabled=$commentsDisabled', ({ tooltip, commentsDisabled }) => { createComponent({ - line: { ...thisLine, commentsDisabled }, + line: applyMap({ ...thisLine, commentsDisabled }), }); wrapper.setData({ isHover: true }); @@ -216,7 +226,7 @@ describe('InlineDiffTableRow', () => { beforeEach(() => { jest.spyOn(store, 'dispatch').mockImplementation(); createComponent({ - line: { ...thisLine, ...lineProps }, + line: applyMap({ ...thisLine, ...lineProps }), }); }); @@ -268,7 +278,7 @@ describe('InlineDiffTableRow', () => { describe('with showCommentButton', () => { it('renders if line has discussions', () => { - createComponent({ line }); + createComponent({ line: applyMap(line) }); expect(findAvatars().props()).toEqual({ discussions: line.discussions, @@ -278,13 +288,13 @@ describe('InlineDiffTableRow', () => { it('does notrender if line has no discussions', () => { line.discussions = []; - createComponent({ line }); + createComponent({ line: applyMap(line) }); expect(findAvatars().exists()).toEqual(false); }); it('toggles line discussion', () => { - createComponent({ line }); + createComponent({ line: applyMap(line) }); expect(store.dispatch).toHaveBeenCalledTimes(1); diff --git a/spec/frontend/diffs/components/inline_diff_view_spec.js b/spec/frontend/diffs/components/inline_diff_view_spec.js index 39c581e2796..6a1791509fd 100644 --- a/spec/frontend/diffs/components/inline_diff_view_spec.js +++ b/spec/frontend/diffs/components/inline_diff_view_spec.js @@ -1,54 +1,57 @@ -import Vue from 'vue'; import '~/behaviors/markdown/render_gfm'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; +import { getByText } from '@testing-library/dom'; import { createStore } from '~/mr_notes/stores'; import InlineDiffView from '~/diffs/components/inline_diff_view.vue'; +import { mapInline } from '~/diffs/components/diff_row_utils'; import diffFileMockData from '../mock_data/diff_file'; import discussionsMockData from '../mock_data/diff_discussions'; describe('InlineDiffView', () => { - let component; + let wrapper; const getDiffFileMock = () => ({ ...diffFileMockData }); const getDiscussionsMockData = () => [{ ...discussionsMockData }]; const notesLength = getDiscussionsMockData()[0].notes.length; - beforeEach(done => { - const diffFile = getDiffFileMock(); + const setup = (diffFile, diffLines) => { + const mockDiffContent = { + diffFile, + shouldRenderDraftRow: jest.fn(), + }; const store = createStore(); store.dispatch('diffs/setInlineDiffViewType'); - component = createComponentWithStore(Vue.extend(InlineDiffView), store, { - diffFile, - diffLines: diffFile.highlighted_diff_lines, - }).$mount(); - - Vue.nextTick(done); - }); + wrapper = mount(InlineDiffView, { + store, + propsData: { + diffFile, + diffLines: diffLines.map(mapInline(mockDiffContent)), + }, + }); + }; describe('template', () => { it('should have rendered diff lines', () => { - const el = component.$el; + const diffFile = getDiffFileMock(); + setup(diffFile, diffFile.highlighted_diff_lines); - expect(el.querySelectorAll('tr.line_holder').length).toEqual(8); - expect(el.querySelectorAll('tr.line_holder.new').length).toEqual(4); - expect(el.querySelectorAll('tr.line_expansion.match').length).toEqual(1); - expect(el.textContent.indexOf('Bad dates')).toBeGreaterThan(-1); + expect(wrapper.findAll('tr.line_holder').length).toEqual(8); + expect(wrapper.findAll('tr.line_holder.new').length).toEqual(4); + expect(wrapper.findAll('tr.line_expansion.match').length).toEqual(1); + getByText(wrapper.element, /Bad dates/i); }); - it('should render discussions', done => { - const el = component.$el; - component.diffLines[1].discussions = getDiscussionsMockData(); - component.diffLines[1].discussionsExpanded = true; - - Vue.nextTick(() => { - expect(el.querySelectorAll('.notes_holder').length).toEqual(1); - expect(el.querySelectorAll('.notes_holder .note').length).toEqual(notesLength + 1); - expect(el.innerText.indexOf('comment 5')).toBeGreaterThan(-1); - component.$store.dispatch('setInitialNotes', []); + it('should render discussions', () => { + const diffFile = getDiffFileMock(); + diffFile.highlighted_diff_lines[1].discussions = getDiscussionsMockData(); + diffFile.highlighted_diff_lines[1].discussionsExpanded = true; + setup(diffFile, diffFile.highlighted_diff_lines); - done(); - }); + expect(wrapper.findAll('.notes_holder').length).toEqual(1); + expect(wrapper.findAll('.notes_holder .note').length).toEqual(notesLength + 1); + getByText(wrapper.element, 'comment 5'); + wrapper.vm.$store.dispatch('setInitialNotes', []); }); }); }); diff --git a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js b/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js deleted file mode 100644 index 38112445e8d..00000000000 --- a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js +++ /dev/null @@ -1,31 +0,0 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { createStore } from '~/mr_notes/stores'; -import ParallelDiffExpansionRow from '~/diffs/components/parallel_diff_expansion_row.vue'; -import diffFileMockData from '../mock_data/diff_file'; - -describe('ParallelDiffExpansionRow', () => { - const matchLine = diffFileMockData.highlighted_diff_lines[5]; - - const createComponent = (options = {}) => { - const cmp = Vue.extend(ParallelDiffExpansionRow); - const defaults = { - fileHash: diffFileMockData.file_hash, - contextLinesPath: 'contextLinesPath', - line: matchLine, - isTop: false, - isBottom: false, - }; - const props = { ...defaults, ...options }; - - return createComponentWithStore(cmp, createStore(), props).$mount(); - }; - - describe('template', () => { - it('should render expansion row for match lines', () => { - const vm = createComponent(); - - expect(vm.$el.classList.contains('line_expansion')).toBe(true); - }); - }); -}); diff --git a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js index 13031bd8b66..57eff177261 100644 --- a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js @@ -3,11 +3,22 @@ import { shallowMount } from '@vue/test-utils'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import { createStore } from '~/mr_notes/stores'; import ParallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue'; +import { mapParallel } from '~/diffs/components/diff_row_utils'; import diffFileMockData from '../mock_data/diff_file'; import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; import discussionsMockData from '../mock_data/diff_discussions'; describe('ParallelDiffTableRow', () => { + const mockDiffContent = { + diffFile: diffFileMockData, + shouldRenderDraftRow: jest.fn(), + hasParallelDraftLeft: jest.fn(), + hasParallelDraftRight: jest.fn(), + draftForLine: jest.fn(), + }; + + const applyMap = mapParallel(mockDiffContent); + describe('when one side is empty', () => { let wrapper; let vm; @@ -18,7 +29,7 @@ describe('ParallelDiffTableRow', () => { wrapper = shallowMount(ParallelDiffTableRow, { store: createStore(), propsData: { - line: thisLine, + line: applyMap(thisLine), fileHash: diffFileMockData.file_hash, filePath: diffFileMockData.file_path, contextLinesPath: 'contextLinesPath', @@ -67,7 +78,7 @@ describe('ParallelDiffTableRow', () => { beforeEach(() => { vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), { - line: thisLine, + line: applyMap(thisLine), fileHash: diffFileMockData.file_hash, filePath: diffFileMockData.file_path, contextLinesPath: 'contextLinesPath', @@ -243,7 +254,10 @@ describe('ParallelDiffTableRow', () => { ${{ ...thisLine, left: { type: 'old-nonewline', discussions: [] } }} | ${false} ${{ ...thisLine, left: { discussions: [{}] } }} | ${false} `('visible is $expectation - line ($line)', async ({ line, expectation }) => { - createComponent({ line }, store, { isLeftHover: true, isCommentButtonRendered: true }); + createComponent({ line: applyMap(line) }, store, { + isLeftHover: true, + isCommentButtonRendered: true, + }); expect(findNoteButton().isVisible()).toBe(expectation); }); @@ -320,7 +334,7 @@ describe('ParallelDiffTableRow', () => { Object.assign(thisLine.left, lineProps); Object.assign(thisLine.right, lineProps); createComponent({ - line: { ...thisLine }, + line: applyMap({ ...thisLine }), }); }); @@ -357,7 +371,7 @@ describe('ParallelDiffTableRow', () => { beforeEach(() => { jest.spyOn(store, 'dispatch').mockImplementation(); - line = { + line = applyMap({ left: { line_code: TEST_LINE_CODE, type: 'new', @@ -369,7 +383,7 @@ describe('ParallelDiffTableRow', () => { rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', meta_data: null, }, - }; + }); }); describe('with showCommentButton', () => { @@ -384,7 +398,7 @@ describe('ParallelDiffTableRow', () => { it('does notrender if line has no discussions', () => { line.left.discussions = []; - createComponent({ line }); + createComponent({ line: applyMap(line) }); expect(findAvatars().exists()).toEqual(false); }); diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js index cc177a81d88..c89403e4869 100644 --- a/spec/frontend/diffs/components/tree_list_spec.js +++ b/spec/frontend/diffs/components/tree_list_spec.js @@ -91,12 +91,12 @@ describe('Diffs tree list component', () => { expect( getFileRows() .at(0) - .text(), + .html(), ).toContain('index.js'); expect( getFileRows() .at(1) - .text(), + .html(), ).toContain('app'); }); @@ -138,7 +138,7 @@ describe('Diffs tree list component', () => { wrapper.vm.$store.state.diffs.renderTreeList = false; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find('.file-row').text()).toContain('index.js'); + expect(wrapper.find('.file-row').html()).toContain('index.js'); }); }); }); diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js index d3886819a91..cef776c885a 100644 --- a/spec/frontend/diffs/mock_data/diff_file.js +++ b/spec/frontend/diffs/mock_data/diff_file.js @@ -27,6 +27,7 @@ export default { name: 'text', error: null, automaticallyCollapsed: false, + manuallyCollapsed: null, }, added_lines: 2, removed_lines: 0, diff --git a/spec/frontend/diffs/mock_data/diff_file_unreadable.js b/spec/frontend/diffs/mock_data/diff_file_unreadable.js index f6cdca9950a..2a5d694e3b8 100644 --- a/spec/frontend/diffs/mock_data/diff_file_unreadable.js +++ b/spec/frontend/diffs/mock_data/diff_file_unreadable.js @@ -26,6 +26,7 @@ export default { name: 'text', error: null, automaticallyCollapsed: false, + manuallyCollapsed: null, }, added_lines: 0, removed_lines: 0, diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index c3e4ee9c531..0af5ddd9764 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -27,7 +27,6 @@ import { scrollToLineIfNeededInline, scrollToLineIfNeededParallel, loadCollapsedDiff, - expandAllFiles, toggleFileDiscussions, saveDiffDiscussion, setHighlightedRow, @@ -42,7 +41,7 @@ import { fetchFullDiff, toggleFullDiff, switchToFullDiffFromRenamedFile, - setFileCollapsed, + setFileCollapsedByUser, setExpandedDiffLines, setSuggestPopoverDismissed, changeCurrentCommit, @@ -658,23 +657,6 @@ describe('DiffsStoreActions', () => { }); }); - describe('expandAllFiles', () => { - it('should change the collapsed prop from the diffFiles', done => { - testAction( - expandAllFiles, - null, - {}, - [ - { - type: types.EXPAND_ALL_FILES, - }, - ], - [], - done, - ); - }); - }); - describe('toggleFileDiscussions', () => { it('should dispatch collapseDiscussion when all discussions are expanded', () => { const getters = { @@ -1167,7 +1149,11 @@ describe('DiffsStoreActions', () => { file_hash: 'testhash', alternate_viewer: { name: updatedViewerName }, }; - const updatedViewer = { name: updatedViewerName, automaticallyCollapsed: false }; + const updatedViewer = { + name: updatedViewerName, + automaticallyCollapsed: false, + manuallyCollapsed: false, + }; const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }]; let renamedFile; let mock; @@ -1216,13 +1202,18 @@ describe('DiffsStoreActions', () => { }); }); - describe('setFileCollapsed', () => { + describe('setFileUserCollapsed', () => { it('commits SET_FILE_COLLAPSED', done => { testAction( - setFileCollapsed, + setFileCollapsedByUser, { filePath: 'test', collapsed: true }, null, - [{ type: types.SET_FILE_COLLAPSED, payload: { filePath: 'test', collapsed: true } }], + [ + { + type: types.SET_FILE_COLLAPSED, + payload: { filePath: 'test', collapsed: true, trigger: 'manual' }, + }, + ], [], done, ); diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js index 0083f1d8b44..7e936c561fc 100644 --- a/spec/frontend/diffs/store/getters_spec.js +++ b/spec/frontend/diffs/store/getters_spec.js @@ -49,23 +49,53 @@ describe('Diffs Module Getters', () => { }); }); - describe('hasCollapsedFile', () => { - it('returns true when all files are collapsed', () => { - localState.diffFiles = [ - { viewer: { automaticallyCollapsed: true } }, - { viewer: { automaticallyCollapsed: true } }, - ]; + describe('whichCollapsedTypes', () => { + const autoCollapsedFile = { viewer: { automaticallyCollapsed: true, manuallyCollapsed: null } }; + const manuallyCollapsedFile = { + viewer: { automaticallyCollapsed: false, manuallyCollapsed: true }, + }; + const openFile = { viewer: { automaticallyCollapsed: false, manuallyCollapsed: false } }; + + it.each` + description | value | files + ${'all files are automatically collapsed'} | ${true} | ${[{ ...autoCollapsedFile }, { ...autoCollapsedFile }]} + ${'all files are manually collapsed'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...manuallyCollapsedFile }]} + ${'no files are collapsed in any way'} | ${false} | ${[{ ...openFile }, { ...openFile }]} + ${'some files are collapsed in either way'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...autoCollapsedFile }, { ...openFile }]} + `('`any` is $value when $description', ({ value, files }) => { + localState.diffFiles = files; + + const getterResult = getters.whichCollapsedTypes(localState); + + expect(getterResult.any).toEqual(value); + }); + + it.each` + description | value | files + ${'all files are automatically collapsed'} | ${true} | ${[{ ...autoCollapsedFile }, { ...autoCollapsedFile }]} + ${'all files are manually collapsed'} | ${false} | ${[{ ...manuallyCollapsedFile }, { ...manuallyCollapsedFile }]} + ${'no files are collapsed in any way'} | ${false} | ${[{ ...openFile }, { ...openFile }]} + ${'some files are collapsed in either way'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...autoCollapsedFile }, { ...openFile }]} + `('`automatic` is $value when $description', ({ value, files }) => { + localState.diffFiles = files; - expect(getters.hasCollapsedFile(localState)).toEqual(true); + const getterResult = getters.whichCollapsedTypes(localState); + + expect(getterResult.automatic).toEqual(value); }); - it('returns true when at least one file is collapsed', () => { - localState.diffFiles = [ - { viewer: { automaticallyCollapsed: false } }, - { viewer: { automaticallyCollapsed: true } }, - ]; + it.each` + description | value | files + ${'all files are automatically collapsed'} | ${false} | ${[{ ...autoCollapsedFile }, { ...autoCollapsedFile }]} + ${'all files are manually collapsed'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...manuallyCollapsedFile }]} + ${'no files are collapsed in any way'} | ${false} | ${[{ ...openFile }, { ...openFile }]} + ${'some files are collapsed in either way'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...autoCollapsedFile }, { ...openFile }]} + `('`manual` is $value when $description', ({ value, files }) => { + localState.diffFiles = files; + + const getterResult = getters.whichCollapsedTypes(localState); - expect(getters.hasCollapsedFile(localState)).toEqual(true); + expect(getterResult.manual).toEqual(value); }); }); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index a84ad63c695..c0645faf89e 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -126,21 +126,6 @@ describe('DiffsStoreMutations', () => { }); }); - describe('EXPAND_ALL_FILES', () => { - it('should change the collapsed prop from diffFiles', () => { - const diffFile = { - viewer: { - automaticallyCollapsed: true, - }, - }; - const state = { expandAllFiles: true, diffFiles: [diffFile] }; - - mutations[types.EXPAND_ALL_FILES](state); - - expect(state.diffFiles[0].viewer.automaticallyCollapsed).toEqual(false); - }); - }); - describe('ADD_CONTEXT_LINES', () => { it('should call utils.addContextLines with proper params', () => { const options = { diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 39a482c85ae..866be0abd22 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -1221,5 +1221,26 @@ describe('DiffsStoreUtils', () => { file.parallel_diff_lines, ); }); + + /** + * What's going on here? + * + * The inline version of parallelizeDiffLines simply keeps the difflines + * in the same order they are received as opposed to shuffling them + * to be "side by side". + * + * This keeps the underlying data structure the same which simplifies + * the components, but keeps the changes grouped together as users + * expect when viewing changes inline. + */ + it('converts inline diff lines to inline diff lines with a parallel structure', () => { + const file = getDiffFileMock(); + const files = utils.parallelizeDiffLines(file.highlighted_diff_lines, true); + + expect(files[5].left).toEqual(file.parallel_diff_lines[5].left); + expect(files[5].right).toBeNull(); + expect(files[6].left).toBeNull(); + expect(files[6].right).toEqual(file.parallel_diff_lines[5].right); + }); }); }); diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js index bc17435c6d4..2968984df01 100644 --- a/spec/frontend/editor/editor_lite_spec.js +++ b/spec/frontend/editor/editor_lite_spec.js @@ -64,7 +64,7 @@ describe('Base editor', () => { }); it('creates model to be supplied to Monaco editor', () => { - editor.createInstance({ el: editorEl, blobPath, blobContent }); + editor.createInstance({ el: editorEl, blobPath, blobContent, blobGlobalId: '' }); expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, createUri(blobPath)); expect(setModel).toHaveBeenCalledWith(fakeModel); diff --git a/spec/frontend/environments/environment_delete_spec.js b/spec/frontend/environments/environment_delete_spec.js index b4ecb24cbac..a8c288a3bd8 100644 --- a/spec/frontend/environments/environment_delete_spec.js +++ b/spec/frontend/environments/environment_delete_spec.js @@ -1,11 +1,9 @@ -import $ from 'jquery'; +import { GlButton } from '@gitlab/ui'; + import { shallowMount } from '@vue/test-utils'; import DeleteComponent from '~/environments/components/environment_delete.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '~/environments/event_hub'; -$.fn.tooltip = () => {}; - describe('External URL Component', () => { let wrapper; @@ -17,7 +15,7 @@ describe('External URL Component', () => { }); }; - const findButton = () => wrapper.find(LoadingButton); + const findButton = () => wrapper.find(GlButton); beforeEach(() => { jest.spyOn(window, 'confirm'); diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index 31f355ce6f1..a77bf39cb54 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -1,12 +1,6 @@ import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; -import { - GlEmptyState, - GlLoadingIcon, - GlFormInput, - GlPagination, - GlDeprecatedDropdown, -} from '@gitlab/ui'; +import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination, GlDropdown } from '@gitlab/ui'; import stubChildren from 'helpers/stub_children'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; import ErrorTrackingActions from '~/error_tracking/components/error_tracking_actions.vue'; @@ -24,19 +18,19 @@ describe('ErrorTrackingList', () => { const findErrorListTable = () => wrapper.find('table'); const findErrorListRows = () => wrapper.findAll('tbody tr'); - const dropdownsArray = () => wrapper.findAll(GlDeprecatedDropdown); + const dropdownsArray = () => wrapper.findAll(GlDropdown); const findRecentSearchesDropdown = () => dropdownsArray() .at(0) - .find(GlDeprecatedDropdown); + .find(GlDropdown); const findStatusFilterDropdown = () => dropdownsArray() .at(1) - .find(GlDeprecatedDropdown); + .find(GlDropdown); const findSortDropdown = () => dropdownsArray() .at(2) - .find(GlDeprecatedDropdown); + .find(GlDropdown); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findPagination = () => wrapper.find(GlPagination); const findErrorActions = () => wrapper.find(ErrorTrackingActions); @@ -134,8 +128,8 @@ describe('ErrorTrackingList', () => { mountComponent({ stubs: { GlTable: false, - GlDeprecatedDropdown: false, - GlDeprecatedDropdownItem: false, + GlDropdown: false, + GlDropdownItem: false, GlLink: false, }, }); @@ -205,8 +199,8 @@ describe('ErrorTrackingList', () => { mountComponent({ stubs: { GlTable: false, - GlDeprecatedDropdown: false, - GlDeprecatedDropdownItem: false, + GlDropdown: false, + GlDropdownItem: false, }, }); }); @@ -325,8 +319,8 @@ describe('ErrorTrackingList', () => { beforeEach(() => { mountComponent({ stubs: { - GlDeprecatedDropdown: false, - GlDeprecatedDropdownItem: false, + GlDropdown: false, + GlDropdownItem: false, }, }); }); diff --git a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js index 023a3e26781..d924f895da8 100644 --- a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js +++ b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js @@ -1,7 +1,7 @@ import { pick, clone } from 'lodash'; import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue'; import { defaultProps, projectList, staleProject } from '../mock'; @@ -43,7 +43,7 @@ describe('error tracking settings project dropdown', () => { describe('empty project list', () => { it('renders the dropdown', () => { expect(wrapper.find('#project-dropdown').exists()).toBeTruthy(); - expect(wrapper.find(GlDeprecatedDropdown).exists()).toBeTruthy(); + expect(wrapper.find(GlDropdown).exists()).toBeTruthy(); }); it('shows helper text', () => { @@ -58,8 +58,8 @@ describe('error tracking settings project dropdown', () => { }); it('does not contain any dropdown items', () => { - expect(wrapper.find(GlDeprecatedDropdownItem).exists()).toBeFalsy(); - expect(wrapper.find(GlDeprecatedDropdown).props('text')).toBe('No projects available'); + expect(wrapper.find(GlDropdownItem).exists()).toBeFalsy(); + expect(wrapper.find(GlDropdown).props('text')).toBe('No projects available'); }); }); @@ -72,12 +72,12 @@ describe('error tracking settings project dropdown', () => { it('renders the dropdown', () => { expect(wrapper.find('#project-dropdown').exists()).toBeTruthy(); - expect(wrapper.find(GlDeprecatedDropdown).exists()).toBeTruthy(); + expect(wrapper.find(GlDropdown).exists()).toBeTruthy(); }); it('contains a number of dropdown items', () => { - expect(wrapper.find(GlDeprecatedDropdownItem).exists()).toBeTruthy(); - expect(wrapper.findAll(GlDeprecatedDropdownItem).length).toBe(2); + expect(wrapper.find(GlDropdownItem).exists()).toBeTruthy(); + expect(wrapper.findAll(GlDropdownItem).length).toBe(2); }); }); diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js index 0e364c47f8d..67f4bee766b 100644 --- a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js +++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js @@ -99,13 +99,13 @@ describe('Configure Feature Flags Modal', () => { }); it('should display the api URL in an input box', () => { - const input = wrapper.find('#api_url'); - expect(input.element.value).toBe('/api/url'); + const input = wrapper.find('#api-url'); + expect(input.attributes('value')).toBe('/api/url'); }); it('should display the instance ID in an input box', () => { const input = wrapper.find('#instance_id'); - expect(input.element.value).toBe('instance-id-token'); + expect(input.attributes('value')).toBe('instance-id-token'); }); }); @@ -129,7 +129,7 @@ describe('Configure Feature Flags Modal', () => { expect(findPrimaryAction()).toBe(null); }); - it('shold not display regenerating instance ID', async () => { + it('should not display regenerating instance ID', async () => { expect(findDangerCallout().exists()).toBe(false); }); diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js index 33c7eeb54b7..2c2a726d26f 100644 --- a/spec/frontend/feature_flags/components/form_spec.js +++ b/spec/frontend/feature_flags/components/form_spec.js @@ -442,12 +442,6 @@ describe('feature flag form', () => { }); }); - it('should request the user lists on mount', () => { - return wrapper.vm.$nextTick(() => { - expect(Api.fetchFeatureFlagUserLists).toHaveBeenCalledWith('1'); - }); - }); - it('should show the strategy component', () => { const strategy = wrapper.find(Strategy); expect(strategy.exists()).toBe(true); @@ -485,9 +479,5 @@ describe('feature flag form', () => { expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy); }); }); - - it('should provide the user lists to the strategy', () => { - expect(wrapper.find(Strategy).props('userLists')).toEqual([userList]); - }); }); }); diff --git a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js index f3f70a325d0..725f53d4409 100644 --- a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js +++ b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js @@ -100,7 +100,7 @@ describe('feature_flags/components/strategies/flexible_rollout.vue', () => { }); }); - describe('with percentage that is not a whole number', () => { + describe('with percentage that is not an integer number', () => { beforeEach(() => { wrapper = factory({ strategy: { parameters: { rollout: '3.14' } } }); }); diff --git a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js index 014c6dd98b9..b34fe7779e3 100644 --- a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js +++ b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js @@ -1,51 +1,103 @@ -import { mount } from '@vue/test-utils'; -import { GlFormSelect } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import Api from '~/api'; +import createStore from '~/feature_flags/store/new'; import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue'; import { userListStrategy, userList } from '../../mock_data'; +jest.mock('~/api'); + const DEFAULT_PROPS = { strategy: userListStrategy, - userLists: [userList], }; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => { let wrapper; const factory = (props = {}) => - mount(GitlabUserList, { propsData: { ...DEFAULT_PROPS, ...props } }); + mount(GitlabUserList, { + localVue, + store: createStore({ projectId: '1' }), + propsData: { ...DEFAULT_PROPS, ...props }, + }); + + const findDropdown = () => wrapper.find(GlDropdown); describe('with user lists', () => { + const findDropdownItem = () => wrapper.find(GlDropdownItem); + beforeEach(() => { + Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] }); wrapper = factory(); }); it('should show the input for userListId with the correct value', () => { - const inputWrapper = wrapper.find(GlFormSelect); - expect(inputWrapper.exists()).toBe(true); - expect(inputWrapper.element.value).toBe('2'); + const dropdownWrapper = findDropdown(); + expect(dropdownWrapper.exists()).toBe(true); + expect(dropdownWrapper.props('text')).toBe(userList.name); + }); + + it('should show a check for the selected list', () => { + const itemWrapper = findDropdownItem(); + expect(itemWrapper.props('isChecked')).toBe(true); + }); + + it('should display the name of the list in the drop;down', () => { + const itemWrapper = findDropdownItem(); + expect(itemWrapper.text()).toBe(userList.name); }); it('should emit a change event when altering the userListId', () => { - const inputWrapper = wrapper.find(GitlabUserList); - inputWrapper.vm.$emit('change', { - userListId: '3', - }); + const inputWrapper = findDropdownItem(); + inputWrapper.vm.$emit('click'); expect(wrapper.emitted('change')).toEqual([ [ { - userListId: '3', + userList, }, ], ]); }); + + it('should search when the filter changes', async () => { + let r; + Api.searchFeatureFlagUserLists.mockReturnValue( + new Promise(resolve => { + r = resolve; + }), + ); + const searchWrapper = wrapper.find(GlSearchBoxByType); + searchWrapper.vm.$emit('input', 'new'); + await wrapper.vm.$nextTick(); + const loadingIcon = wrapper.find(GlLoadingIcon); + + expect(loadingIcon.exists()).toBe(true); + expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'new'); + + r({ data: [userList] }); + + await wrapper.vm.$nextTick(); + + expect(loadingIcon.exists()).toBe(false); + }); }); + describe('without user lists', () => { beforeEach(() => { - wrapper = factory({ userLists: [] }); + Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [] }); + wrapper = factory({ strategy: { ...userListStrategy, userList: null } }); }); it('should display a message that there are no user lists', () => { expect(wrapper.text()).toContain('There are no configured user lists'); }); + + it('should dispaly a message that no list has been selected', () => { + expect(findDropdown().text()).toContain('No user list selected'); + }); }); }); diff --git a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js index de0b439f1c5..696b3b2e4c9 100644 --- a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js +++ b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js @@ -63,7 +63,7 @@ describe('~/feature_flags/components/strategies/percent_rollout.vue', () => { }); }); - describe('with percentage that is not a whole number', () => { + describe('with percentage that is not an integer number', () => { beforeEach(() => { wrapper = factory({ strategy: { parameters: { percentage: '3.14' } } }); diff --git a/spec/frontend/feature_flags/components/strategy_parameters_spec.js b/spec/frontend/feature_flags/components/strategy_parameters_spec.js index 314fb0f21f4..a024384e623 100644 --- a/spec/frontend/feature_flags/components/strategy_parameters_spec.js +++ b/spec/frontend/feature_flags/components/strategy_parameters_spec.js @@ -11,11 +11,10 @@ import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_li import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue'; import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue'; import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue'; -import { allUsersStrategy, userList } from '../mock_data'; +import { allUsersStrategy } from '../mock_data'; const DEFAULT_PROPS = { strategy: allUsersStrategy, - userLists: [userList], }; describe('~/feature_flags/components/strategy_parameters.vue', () => { @@ -71,13 +70,14 @@ describe('~/feature_flags/components/strategy_parameters.vue', () => { describe('pass through props', () => { it('should pass through any extra props that might be needed', () => { + const strategy = { + name: ROLLOUT_STRATEGY_USER_ID, + }; wrapper = factory({ - strategy: { - name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, - }, + strategy, }); - expect(wrapper.find(GitlabUserList).props('userLists')).toEqual([userList]); + expect(wrapper.find(UsersWithId).props('strategy')).toEqual(strategy); }); }); }); diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js index 7d6700ba184..67cf70c37e2 100644 --- a/spec/frontend/feature_flags/components/strategy_spec.js +++ b/spec/frontend/feature_flags/components/strategy_spec.js @@ -1,6 +1,9 @@ -import { mount } from '@vue/test-utils'; +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; import { last } from 'lodash'; import { GlAlert, GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui'; +import Api from '~/api'; +import createStore from '~/feature_flags/store/new'; import { PERCENT_ROLLOUT_GROUP_ID, ROLLOUT_STRATEGY_ALL_USERS, @@ -15,12 +18,17 @@ import StrategyParameters from '~/feature_flags/components/strategy_parameters.v import { userList } from '../mock_data'; +jest.mock('~/api'); + const provide = { strategyTypeDocsPagePath: 'link-to-strategy-docs', environmentsScopeDocsPath: 'link-scope-docs', environmentsEndpoint: '', }; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('Feature flags strategy', () => { let wrapper; @@ -32,7 +40,6 @@ describe('Feature flags strategy', () => { propsData: { strategy: {}, index: 0, - userLists: [userList], }, provide, }, @@ -41,9 +48,13 @@ describe('Feature flags strategy', () => { wrapper.destroy(); wrapper = null; } - wrapper = mount(Strategy, opts); + wrapper = mount(Strategy, { localVue, store: createStore({ projectId: '1' }), ...opts }); }; + beforeEach(() => { + Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] }); + }); + afterEach(() => { if (wrapper) { wrapper.destroy(); diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js index ed06ea059a7..11a91e5b2a8 100644 --- a/spec/frontend/feature_flags/mock_data.js +++ b/spec/frontend/feature_flags/mock_data.js @@ -127,7 +127,7 @@ export const userListStrategy = { name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, parameters: {}, scopes: [], - userListId: userList.id, + userList, }; export const percentRolloutStrategy = { diff --git a/spec/frontend/feature_flags/store/gitlab_user_lists/actions_spec.js b/spec/frontend/feature_flags/store/gitlab_user_lists/actions_spec.js new file mode 100644 index 00000000000..aba578cca59 --- /dev/null +++ b/spec/frontend/feature_flags/store/gitlab_user_lists/actions_spec.js @@ -0,0 +1,60 @@ +import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; +import createState from '~/feature_flags/store/gitlab_user_list/state'; +import { fetchUserLists, setFilter } from '~/feature_flags/store/gitlab_user_list/actions'; +import * as types from '~/feature_flags/store/gitlab_user_list/mutation_types'; +import { userList } from '../../mock_data'; + +jest.mock('~/api'); + +describe('~/feature_flags/store/gitlab_user_list/actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = createState({ projectId: '1' }); + mockedState.filter = 'test'; + }); + + describe('fetchUserLists', () => { + it('should commit FETCH_USER_LISTS and RECEIEVE_USER_LISTS_SUCCESS on success', () => { + Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] }); + return testAction( + fetchUserLists, + undefined, + mockedState, + [ + { type: types.FETCH_USER_LISTS }, + { type: types.RECEIVE_USER_LISTS_SUCCESS, payload: [userList] }, + ], + [], + () => expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'test'), + ); + }); + + it('should commit FETCH_USER_LISTS and RECEIEVE_USER_LISTS_ERROR on success', () => { + Api.searchFeatureFlagUserLists.mockRejectedValue({ message: 'error' }); + return testAction( + fetchUserLists, + undefined, + mockedState, + [ + { type: types.FETCH_USER_LISTS }, + { type: types.RECEIVE_USER_LISTS_ERROR, payload: ['error'] }, + ], + [], + () => expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'test'), + ); + }); + }); + + describe('setFilter', () => { + it('commits SET_FILTER and fetches new user lists', () => + testAction( + setFilter, + 'filter', + mockedState, + [{ type: types.SET_FILTER, payload: 'filter' }], + [{ type: 'fetchUserLists' }], + )); + }); +}); diff --git a/spec/frontend/feature_flags/store/gitlab_user_lists/getters_spec.js b/spec/frontend/feature_flags/store/gitlab_user_lists/getters_spec.js new file mode 100644 index 00000000000..e267cd59f50 --- /dev/null +++ b/spec/frontend/feature_flags/store/gitlab_user_lists/getters_spec.js @@ -0,0 +1,69 @@ +import { + userListOptions, + hasUserLists, + isLoading, + hasError, +} from '~/feature_flags/store/gitlab_user_list/getters'; +import statuses from '~/feature_flags/store/gitlab_user_list/status'; +import createState from '~/feature_flags/store/gitlab_user_list/state'; +import { userList } from '../../mock_data'; + +describe('~/feature_flags/store/gitlab_user_list/getters', () => { + let mockedState; + + beforeEach(() => { + mockedState = createState({ projectId: '8' }); + mockedState.userLists = [userList]; + }); + + describe('userListOption', () => { + it('should return user lists in a way usable by a dropdown', () => { + expect(userListOptions(mockedState)).toEqual([{ value: userList.id, text: userList.name }]); + }); + + it('should return an empty array if there are no lists', () => { + mockedState.userLists = []; + expect(userListOptions(mockedState)).toEqual([]); + }); + }); + + describe('hasUserLists', () => { + it.each` + userLists | status | result + ${[userList]} | ${statuses.IDLE} | ${true} + ${[]} | ${statuses.IDLE} | ${false} + ${[]} | ${statuses.START} | ${true} + `( + 'should return $result if there are $userLists.length user lists and the status is $status', + ({ userLists, status, result }) => { + mockedState.userLists = userLists; + mockedState.status = status; + expect(hasUserLists(mockedState)).toBe(result); + }, + ); + }); + + describe('isLoading', () => { + it.each` + status | result + ${statuses.LOADING} | ${true} + ${statuses.ERROR} | ${false} + ${statuses.IDLE} | ${false} + `('should return $result if the status is "$status"', ({ status, result }) => { + mockedState.status = status; + expect(isLoading(mockedState)).toBe(result); + }); + }); + + describe('hasError', () => { + it.each` + status | result + ${statuses.LOADING} | ${false} + ${statuses.ERROR} | ${true} + ${statuses.IDLE} | ${false} + `('should return $result if the status is "$status"', ({ status, result }) => { + mockedState.status = status; + expect(hasError(mockedState)).toBe(result); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/gitlab_user_lists/mutations_spec.js b/spec/frontend/feature_flags/store/gitlab_user_lists/mutations_spec.js new file mode 100644 index 00000000000..88d4554a227 --- /dev/null +++ b/spec/frontend/feature_flags/store/gitlab_user_lists/mutations_spec.js @@ -0,0 +1,50 @@ +import statuses from '~/feature_flags/store/gitlab_user_list/status'; +import createState from '~/feature_flags/store/gitlab_user_list/state'; +import * as types from '~/feature_flags/store/gitlab_user_list/mutation_types'; +import mutations from '~/feature_flags/store/gitlab_user_list/mutations'; +import { userList } from '../../mock_data'; + +describe('~/feature_flags/store/gitlab_user_list/mutations', () => { + let state; + + beforeEach(() => { + state = createState({ projectId: '8' }); + }); + + describe(types.SET_FILTER, () => { + it('sets the filter in the state', () => { + mutations[types.SET_FILTER](state, 'test'); + expect(state.filter).toBe('test'); + }); + }); + + describe(types.FETCH_USER_LISTS, () => { + it('sets the status to loading', () => { + mutations[types.FETCH_USER_LISTS](state); + expect(state.status).toBe(statuses.LOADING); + }); + }); + + describe(types.RECEIVE_USER_LISTS_SUCCESS, () => { + it('sets the user lists to the ones received', () => { + mutations[types.RECEIVE_USER_LISTS_SUCCESS](state, [userList]); + expect(state.userLists).toEqual([userList]); + }); + + it('sets the status to idle', () => { + mutations[types.RECEIVE_USER_LISTS_SUCCESS](state, [userList]); + expect(state.status).toBe(statuses.IDLE); + }); + }); + describe(types.RECEIVE_USER_LISTS_ERROR, () => { + it('sets the status to error', () => { + mutations[types.RECEIVE_USER_LISTS_ERROR](state, 'failure'); + expect(state.status).toBe(statuses.ERROR); + }); + + it('sets the error message', () => { + mutations[types.RECEIVE_USER_LISTS_ERROR](state, 'failure'); + expect(state.error).toBe('failure'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 53c726a6cea..5c37d986ef1 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -1,3 +1,5 @@ +import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; + import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; @@ -5,7 +7,6 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; -import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes'; import { visitUrl } from '~/lib/utils/url_utility'; diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb index 7695dbc2e8f..193bd0c3ef2 100644 --- a/spec/frontend/fixtures/freeze_period.rb +++ b/spec/frontend/fixtures/freeze_period.rb @@ -17,6 +17,15 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do remove_repository(project) end + around do |example| + freeze_time do + # Mock time to sept 19 (intl. talk like a pirate day) + Timecop.travel(2020, 9, 19) + + example.run + end + end + describe API::FreezePeriods, '(JavaScript fixtures)', type: :request do include ApiHelpers diff --git a/spec/frontend/fixtures/groups.rb b/spec/frontend/fixtures/groups.rb index 6f0d7aa1f7c..9f0b2c73c93 100644 --- a/spec/frontend/fixtures/groups.rb +++ b/spec/frontend/fixtures/groups.rb @@ -15,7 +15,6 @@ RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do end before do - stub_feature_flags(new_variables_ui: false) group.add_maintainer(admin) sign_in(admin) end @@ -27,12 +26,4 @@ RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do expect(response).to be_successful end end - - describe Groups::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do - it 'groups/ci_cd_settings.html' do - get :show, params: { group_id: group } - - expect(response).to be_successful - end - end end diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index 2c380ba6a96..baea87be45f 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr end before do + stub_feature_flags(vue_issue_header: false) + sign_in(admin) end diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index d33909fb98b..d0cedb0ef86 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -20,7 +20,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do end before do - stub_feature_flags(new_variables_ui: false) project.add_maintainer(admin) sign_in(admin) allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') @@ -58,27 +57,4 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do expect(response).to be_successful end end - - describe Projects::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do - it 'projects/ci_cd_settings.html' do - get :show, params: { - namespace_id: project.namespace.to_param, - project_id: project - } - - expect(response).to be_successful - end - - it 'projects/ci_cd_settings_with_variables.html' do - create(:ci_variable, project: project_variable_populated) - create(:ci_variable, project: project_variable_populated) - - get :show, params: { - namespace_id: project_variable_populated.namespace.to_param, - project_id: project_variable_populated - } - - expect(response).to be_successful - end - end end diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb index 40f613a9422..7819d0774a7 100644 --- a/spec/frontend/fixtures/search.rb +++ b/spec/frontend/fixtures/search.rb @@ -26,8 +26,51 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do context 'search within a project' do let(:namespace) { create(:namespace, name: 'frontend-fixtures') } let(:project) { create(:project, :public, :repository, namespace: namespace, path: 'search-project') } + let(:blobs) do + Kaminari.paginate_array([ + Gitlab::Search::FoundBlob.new( + path: 'CHANGELOG', + basename: 'CHANGELOG', + ref: 'master', + data: "hello\nworld\nfoo\nSend # this is the highligh\nbaz\nboo\nbat", + project: project, + project_id: project.id, + startline: 2), + Gitlab::Search::FoundBlob.new( + path: 'CONTRIBUTING', + basename: 'CONTRIBUTING', + ref: 'master', + data: "hello\nworld\nfoo\nSend # this is the highligh\nbaz\nboo\nbat", + project: project, + project_id: project.id, + startline: 2), + Gitlab::Search::FoundBlob.new( + path: 'README', + basename: 'README', + ref: 'master', + data: "foo\nSend # this is the highlight\nbaz\nboo\nbat", + project: project, + project_id: project.id, + startline: 2), + Gitlab::Search::FoundBlob.new( + path: 'test', + basename: 'test', + ref: 'master', + data: "foo\nSend # this is the highlight\nbaz\nboo\nbat", + project: project, + project_id: project.id, + startline: 2) + ], + total_count: 4, + limit: 4, + offset: 0) + end it 'search/blob_search_result.html' do + expect_next_instance_of(SearchService) do |search_service| + expect(search_service).to receive(:search_objects).and_return(blobs) + end + get :show, params: { search: 'Send', project_id: project.id, diff --git a/spec/frontend/fixtures/static/signin_tabs.html b/spec/frontend/fixtures/static/signin_tabs.html index 247a6b03054..7e66ab9394b 100644 --- a/spec/frontend/fixtures/static/signin_tabs.html +++ b/spec/frontend/fixtures/static/signin_tabs.html @@ -5,7 +5,4 @@ <li> <a href="#login-pane">Standard</a> </li> -<li> -<a href="#register-pane">Register</a> -</li> </ul> diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 8da4320d993..eb9343847f1 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -8,15 +8,226 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete import { TEST_HOST } from 'helpers/test_constants'; import { getJSONFixture } from 'helpers/fixtures'; +import waitForPromises from 'jest/helpers/wait_for_promises'; + +import MockAdapter from 'axios-mock-adapter'; +import AjaxCache from '~/lib/utils/ajax_cache'; +import axios from '~/lib/utils/axios_utils'; + const labelsFixture = getJSONFixture('autocomplete_sources/labels.json'); describe('GfmAutoComplete', () => { - const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ - fetchData: () => {}, - }); + const fetchDataMock = { fetchData: jest.fn() }; + let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock); let atwhoInstance; let sorterValue; + let filterValue; + + describe('DefaultOptions.filter', () => { + let items; + + beforeEach(() => { + jest.spyOn(fetchDataMock, 'fetchData'); + jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {}); + }); + + describe('assets loading', () => { + beforeEach(() => { + atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' }; + items = ['loading']; + + filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items); + }); + + it('should call the fetchData function without query', () => { + expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '[vulnerability:'); + }); + + it('should not call the default atwho filter', () => { + expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); + }); + + it('should return the passed unfiltered items', () => { + expect(filterValue).toEqual(items); + }); + }); + + describe('backend filtering', () => { + beforeEach(() => { + atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' }; + items = []; + }); + + describe('when previous query is different from current one', () => { + beforeEach(() => { + gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ + previousQuery: 'oldquery', + ...fetchDataMock, + }); + filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items); + }); + + it('should call the fetchData function with query', () => { + expect(fetchDataMock.fetchData).toHaveBeenCalledWith( + 'inputor', + '[vulnerability:', + 'newquery', + ); + }); + + it('should not call the default atwho filter', () => { + expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); + }); + + it('should return the passed unfiltered items', () => { + expect(filterValue).toEqual(items); + }); + }); + + describe('when previous query is not different from current one', () => { + beforeEach(() => { + gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ + previousQuery: 'oldquery', + ...fetchDataMock, + }); + filterValue = gfmAutoCompleteCallbacks.filter.call( + atwhoInstance, + 'oldquery', + items, + 'searchKey', + ); + }); + + it('should not call the fetchData function', () => { + expect(fetchDataMock.fetchData).not.toHaveBeenCalled(); + }); + + it('should call the default atwho filter', () => { + expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith( + 'oldquery', + items, + 'searchKey', + ); + }); + }); + }); + }); + + describe('fetchData', () => { + const { fetchData } = GfmAutoComplete.prototype; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(axios, 'get'); + jest.spyOn(AjaxCache, 'retrieve'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('already loading data', () => { + beforeEach(() => { + const context = { + isLoadingData: { '[vulnerability:': true }, + dataSources: {}, + cachedData: {}, + }; + fetchData.call(context, {}, '[vulnerability:', ''); + }); + + it('should not call either axios nor AjaxCache', () => { + expect(axios.get).not.toHaveBeenCalled(); + expect(AjaxCache.retrieve).not.toHaveBeenCalled(); + }); + }); + + describe('backend filtering', () => { + describe('data is not in cache', () => { + let context; + + beforeEach(() => { + context = { + isLoadingData: { '[vulnerability:': false }, + dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, + cachedData: {}, + }; + }); + + it('should call axios with query', () => { + fetchData.call(context, {}, '[vulnerability:', 'query'); + + expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { + params: { search: 'query' }, + }); + }); + + it.each([200, 500])('should set the loading state', async responseStatus => { + mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus); + + fetchData.call(context, {}, '[vulnerability:', 'query'); + + expect(context.isLoadingData['[vulnerability:']).toBe(true); + + await waitForPromises(); + + expect(context.isLoadingData['[vulnerability:']).toBe(false); + }); + }); + + describe('data is in cache', () => { + beforeEach(() => { + const context = { + isLoadingData: { '[vulnerability:': false }, + dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, + cachedData: { '[vulnerability:': [{}] }, + }; + fetchData.call(context, {}, '[vulnerability:', 'query'); + }); + + it('should anyway call axios with query ignoring cache', () => { + expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { + params: { search: 'query' }, + }); + }); + }); + }); + + describe('frontend filtering', () => { + describe('data is not in cache', () => { + beforeEach(() => { + const context = { + isLoadingData: { '#': false }, + dataSources: { issues: 'issues_autocomplete_url' }, + cachedData: {}, + }; + fetchData.call(context, {}, '#', 'query'); + }); + + it('should call AjaxCache', () => { + expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true); + }); + }); + + describe('data is in cache', () => { + beforeEach(() => { + const context = { + isLoadingData: { '#': false }, + dataSources: { issues: 'issues_autocomplete_url' }, + cachedData: { '#': [{}] }, + loadData: () => {}, + }; + fetchData.call(context, {}, '#', 'query'); + }); + + it('should not call AjaxCache', () => { + expect(AjaxCache.retrieve).not.toHaveBeenCalled(); + }); + }); + }); + }); describe('DefaultOptions.sorter', () => { describe('assets loading', () => { @@ -154,7 +365,6 @@ describe('GfmAutoComplete', () => { 'я', '.', "'", - '+', '-', '_', ]; @@ -378,6 +588,7 @@ describe('GfmAutoComplete', () => { username: 'my-group', title: '', icon: '', + availabilityStatus: '', }), ).toBe('<li>IMG my-group <small></small> </li>'); }); @@ -389,6 +600,7 @@ describe('GfmAutoComplete', () => { username: 'my-group', title: '', icon: '<i class="icon"/>', + availabilityStatus: '', }), ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>'); }); @@ -400,9 +612,24 @@ describe('GfmAutoComplete', () => { username: 'my-group', title: 'MyGroup+', icon: '<i class="icon"/>', + availabilityStatus: '', }), ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>'); }); + + it('should add user availability status if availabilityStatus is set', () => { + expect( + GfmAutoComplete.Members.templateFunction({ + avatarTag: 'IMG', + username: 'my-group', + title: '', + icon: '<i class="icon"/>', + availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>', + }), + ).toBe( + '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>', + ); + }); }); describe('labels', () => { diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js index 52386bf6ede..6a630195126 100644 --- a/spec/frontend/graphql_shared/utils_spec.js +++ b/spec/frontend/graphql_shared/utils_spec.js @@ -11,6 +11,10 @@ describe('getIdFromGraphQLId', () => { output: null, }, { + input: 2, + output: 2, + }, + { input: 'gid://', output: null, }, diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 5d34bc48ed5..691f8896d74 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -2,6 +2,8 @@ import '~/flash'; import $ from 'jquery'; import Vue from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { GlModal, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import appComponent from '~/groups/components/app.vue'; @@ -23,47 +25,51 @@ import { mockPageInfo, } from '../mock_data'; -const createComponent = (hideProjects = false) => { - const Component = Vue.extend(appComponent); - const store = new GroupsStore(false); - const service = new GroupsService(mockEndpoint); - - store.state.pageInfo = mockPageInfo; - - return new Component({ - propsData: { - store, - service, - hideProjects, - }, - }); +const $toast = { + show: jest.fn(), }; describe('AppComponent', () => { + let wrapper; let vm; let mock; let getGroupsSpy; + const store = new GroupsStore(false); + const service = new GroupsService(mockEndpoint); + + const createShallowComponent = (hideProjects = false) => { + store.state.pageInfo = mockPageInfo; + wrapper = shallowMount(appComponent, { + propsData: { + store, + service, + hideProjects, + }, + mocks: { + $toast, + }, + }); + vm = wrapper.vm; + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + beforeEach(() => { mock = new AxiosMockAdapter(axios); mock.onGet('/dashboard/groups.json').reply(200, mockGroups); Vue.component('group-folder', groupFolderComponent); Vue.component('group-item', groupItemComponent); - vm = createComponent(); + createShallowComponent(); getGroupsSpy = jest.spyOn(vm.service, 'getGroups'); return vm.$nextTick(); }); describe('computed', () => { - beforeEach(() => { - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - describe('groups', () => { it('should return list of groups from store', () => { jest.spyOn(vm.store, 'getGroups').mockImplementation(() => {}); @@ -88,14 +94,6 @@ describe('AppComponent', () => { }); describe('methods', () => { - beforeEach(() => { - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - describe('fetchGroups', () => { it('should call `getGroups` with all the params provided', () => { return vm @@ -284,29 +282,15 @@ describe('AppComponent', () => { it('updates props which show modal confirmation dialog', () => { const group = { ...mockParentGroupItem }; - expect(vm.showModal).toBe(false); expect(vm.groupLeaveConfirmationMessage).toBe(''); vm.showLeaveGroupModal(group, mockParentGroupItem); - expect(vm.showModal).toBe(true); expect(vm.groupLeaveConfirmationMessage).toBe( `Are you sure you want to leave the "${group.fullName}" group?`, ); }); }); - describe('hideLeaveGroupModal', () => { - it('hides modal confirmation which is shown before leaving the group', () => { - const group = { ...mockParentGroupItem }; - vm.showLeaveGroupModal(group, mockParentGroupItem); - - expect(vm.showModal).toBe(true); - vm.hideLeaveGroupModal(); - - expect(vm.showModal).toBe(false); - }); - }); - describe('leaveGroup', () => { let groupItem; let childGroupItem; @@ -324,18 +308,16 @@ describe('AppComponent', () => { const notice = `You left the "${childGroupItem.fullName}" group.`; jest.spyOn(vm.service, 'leaveGroup').mockResolvedValue({ data: { notice } }); jest.spyOn(vm.store, 'removeGroup'); - jest.spyOn(window, 'Flash').mockImplementation(() => {}); jest.spyOn($, 'scrollTo').mockImplementation(() => {}); vm.leaveGroup(); - expect(vm.showModal).toBe(false); expect(vm.targetGroup.isBeingRemoved).toBe(true); expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath); return waitForPromises().then(() => { expect($.scrollTo).toHaveBeenCalledWith(0); expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup); - expect(window.Flash).toHaveBeenCalledWith(notice, 'notice'); + expect($toast.show).toHaveBeenCalledWith(notice); }); }); @@ -417,8 +399,7 @@ describe('AppComponent', () => { it('should bind event listeners on eventHub', () => { jest.spyOn(eventHub, '$on').mockImplementation(() => {}); - const newVm = createComponent(); - newVm.$mount(); + createShallowComponent(); return vm.$nextTick().then(() => { expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', expect.any(Function)); @@ -426,25 +407,20 @@ describe('AppComponent', () => { expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function)); expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', expect.any(Function)); expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', expect.any(Function)); - newVm.$destroy(); }); }); it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', () => { - const newVm = createComponent(); - newVm.$mount(); + createShallowComponent(); return vm.$nextTick().then(() => { - expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search'); - newVm.$destroy(); + expect(vm.searchEmptyMessage).toBe('No groups or projects matched your search'); }); }); it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', () => { - const newVm = createComponent(true); - newVm.$mount(); + createShallowComponent(true); return vm.$nextTick().then(() => { - expect(newVm.searchEmptyMessage).toBe('No groups matched your search'); - newVm.$destroy(); + expect(vm.searchEmptyMessage).toBe('No groups matched your search'); }); }); }); @@ -453,9 +429,8 @@ describe('AppComponent', () => { it('should unbind event listeners on eventHub', () => { jest.spyOn(eventHub, '$off').mockImplementation(() => {}); - const newVm = createComponent(); - newVm.$mount(); - newVm.$destroy(); + createShallowComponent(); + wrapper.destroy(); return vm.$nextTick().then(() => { expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', expect.any(Function)); @@ -468,19 +443,10 @@ describe('AppComponent', () => { }); describe('template', () => { - beforeEach(() => { - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - it('should render loading icon', () => { vm.isLoading = true; return vm.$nextTick().then(() => { - expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); - expect(vm.$el.querySelector('span').getAttribute('aria-label')).toBe('Loading groups'); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); }); @@ -493,15 +459,13 @@ describe('AppComponent', () => { }); it('renders modal confirmation dialog', () => { - vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?'; - vm.showModal = true; - return vm.$nextTick().then(() => { - const modalDialogEl = vm.$el.querySelector('.modal'); + createShallowComponent(); - expect(modalDialogEl).not.toBe(null); - expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?'); - expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave'); - }); + const findGlModal = wrapper.find(GlModal); + + expect(findGlModal.exists()).toBe(true); + expect(findGlModal.attributes('title')).toBe('Are you sure?'); + expect(findGlModal.props('actionPrimary').text).toBe('Leave group'); }); }); }); diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js index d4aa29eaadd..9adbc9abe13 100644 --- a/spec/frontend/groups/components/item_actions_spec.js +++ b/spec/frontend/groups/components/item_actions_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlIcon } from '@gitlab/ui'; import ItemActions from '~/groups/components/item_actions.vue'; import eventHub from '~/groups/event_hub'; import { mockParentGroupItem, mockChildren } from '../mock_data'; @@ -20,18 +19,25 @@ describe('ItemActions', () => { }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); + wrapper = null; }); const findEditGroupBtn = () => wrapper.find('[data-testid="edit-group-btn"]'); - const findEditGroupIcon = () => findEditGroupBtn().find(GlIcon); const findLeaveGroupBtn = () => wrapper.find('[data-testid="leave-group-btn"]'); - const findLeaveGroupIcon = () => findLeaveGroupBtn().find(GlIcon); describe('template', () => { + let group; + + beforeEach(() => { + group = { + ...mockParentGroupItem, + canEdit: true, + canLeave: true, + }; + createComponent({ group }); + }); + it('renders component template correctly', () => { createComponent(); @@ -39,49 +45,46 @@ describe('ItemActions', () => { }); it('renders "Edit group" button with correct attribute values', () => { - const group = { - ...mockParentGroupItem, - canEdit: true, - }; - - createComponent({ group }); - - expect(findEditGroupBtn().exists()).toBe(true); - expect(findEditGroupBtn().classes()).toContain('no-expand'); - expect(findEditGroupBtn().attributes('href')).toBe(group.editPath); - expect(findEditGroupBtn().attributes('aria-label')).toBe('Edit group'); - expect(findEditGroupBtn().attributes('data-original-title')).toBe('Edit group'); - expect(findEditGroupIcon().exists()).toBe(true); - expect(findEditGroupIcon().props('name')).toBe('settings'); + const button = findEditGroupBtn(); + expect(button.exists()).toBe(true); + expect(button.props('icon')).toBe('pencil'); + expect(button.attributes('aria-label')).toBe('Edit group'); }); - describe('`canLeave` is true', () => { - const group = { - ...mockParentGroupItem, - canLeave: true, - }; + it('renders "Leave this group" button with correct attribute values', () => { + const button = findLeaveGroupBtn(); + expect(button.exists()).toBe(true); + expect(button.props('icon')).toBe('leave'); + expect(button.attributes('aria-label')).toBe('Leave this group'); + }); - beforeEach(() => { - createComponent({ group }); - }); + it('emits `showLeaveGroupModal` event in the event hub', () => { + jest.spyOn(eventHub, '$emit'); + findLeaveGroupBtn().vm.$emit('click', { stopPropagation: () => {} }); - it('renders "Leave this group" button with correct attribute values', () => { - expect(findLeaveGroupBtn().exists()).toBe(true); - expect(findLeaveGroupBtn().classes()).toContain('no-expand'); - expect(findLeaveGroupBtn().attributes('href')).toBe(group.leavePath); - expect(findLeaveGroupBtn().attributes('aria-label')).toBe('Leave this group'); - expect(findLeaveGroupBtn().attributes('data-original-title')).toBe('Leave this group'); - expect(findLeaveGroupIcon().exists()).toBe(true); - expect(findLeaveGroupIcon().props('name')).toBe('leave'); - }); + expect(eventHub.$emit).toHaveBeenCalledWith('showLeaveGroupModal', group, parentGroup); + }); + }); - it('emits event on "Leave this group" button click', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + it('does not render leave button if group can not be left', () => { + createComponent({ + group: { + ...mockParentGroupItem, + canLeave: false, + }, + }); - findLeaveGroupBtn().trigger('click'); + expect(findLeaveGroupBtn().exists()).toBe(false); + }); - expect(eventHub.$emit).toHaveBeenCalledWith('showLeaveGroupModal', group, parentGroup); - }); + it('does not render edit button if group can not be edited', () => { + createComponent({ + group: { + ...mockParentGroupItem, + canEdit: false, + }, }); + + expect(findEditGroupBtn().exists()).toBe(false); }); }); diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js index 2fb7904bcfe..aaa36665c45 100644 --- a/spec/frontend/groups/members/index_spec.js +++ b/spec/frontend/groups/members/index_spec.js @@ -9,7 +9,12 @@ describe('initGroupMembersApp', () => { let wrapper; const setup = () => { - vm = initGroupMembersApp(el, ['account'], () => ({})); + vm = initGroupMembersApp( + el, + ['account'], + { table: { 'data-qa-selector': 'members_list' } }, + () => ({}), + ); wrapper = createWrapper(vm); }; @@ -68,6 +73,12 @@ describe('initGroupMembersApp', () => { expect(vm.$store.state.tableFields).toEqual(['account']); }); + it('sets `tableAttrs` in Vuex store', () => { + setup(); + + expect(vm.$store.state.tableAttrs).toEqual({ table: { 'data-qa-selector': 'members_list' } }); + }); + it('sets `requestFormatter` in Vuex store', () => { setup(); diff --git a/spec/frontend/groups/mock_data.js b/spec/frontend/groups/mock_data.js index 380dda9f7b1..603cb27deec 100644 --- a/spec/frontend/groups/mock_data.js +++ b/spec/frontend/groups/mock_data.js @@ -7,13 +7,14 @@ export const ITEM_TYPE = { export const GROUP_VISIBILITY_TYPE = { public: 'Public - The group and any public projects can be viewed without any authentication.', - internal: 'Internal - The group and any internal projects can be viewed by any logged in user.', + internal: + 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', private: 'Private - The group and its projects can only be viewed by members.', }; export const PROJECT_VISIBILITY_TYPE = { public: 'Public - The project can be accessed without any authentication.', - internal: 'Internal - The project can be accessed by any logged in user.', + internal: 'Internal - The project can be accessed by any logged in user except external users.', private: 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', }; diff --git a/spec/frontend/helpers/fake_date.js b/spec/frontend/helpers/fake_date.js index 8417b1c520a..387747ab5bd 100644 --- a/spec/frontend/helpers/fake_date.js +++ b/spec/frontend/helpers/fake_date.js @@ -15,7 +15,7 @@ export const createFakeDateClass = ctorDefault => { apply: (target, thisArg, argArray) => { const ctorArgs = argArray.length ? argArray : ctorDefault; - return RealDate(...ctorArgs); + return new RealDate(...ctorArgs).toString(); }, // We want to overwrite the default 'now', but only if it's not already mocked get: (target, prop) => { diff --git a/spec/frontend/helpers/fake_date_spec.js b/spec/frontend/helpers/fake_date_spec.js index 8afc8225f9b..b3ed13e238a 100644 --- a/spec/frontend/helpers/fake_date_spec.js +++ b/spec/frontend/helpers/fake_date_spec.js @@ -13,13 +13,17 @@ describe('spec/helpers/fake_date', () => { }); it('should use default args', () => { - expect(new FakeDate()).toEqual(new Date(...DEFAULT_ARGS)); - expect(FakeDate()).toEqual(Date(...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()).not.toBe(Date.now()); - expect(FakeDate.now()).toBe(new Date(...DEFAULT_ARGS).getTime()); + expect(FakeDate.now()).toMatchInlineSnapshot(`1593993600000`); }); it('should be instanceof Date', () => { diff --git a/spec/frontend/helpers/mock_apollo_helper.js b/spec/frontend/helpers/mock_apollo_helper.js index 8a5a160231c..914cce1d662 100644 --- a/spec/frontend/helpers/mock_apollo_helper.js +++ b/spec/frontend/helpers/mock_apollo_helper.js @@ -2,14 +2,14 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { createMockClient } from 'mock-apollo-client'; import VueApollo from 'vue-apollo'; -export default (handlers = []) => { +export default (handlers = [], resolvers = {}) => { const fragmentMatcher = { match: () => true }; const cache = new InMemoryCache({ fragmentMatcher, addTypename: false, }); - const mockClient = createMockClient({ cache }); + const mockClient = createMockClient({ cache, resolvers }); if (Array.isArray(handlers)) { handlers.forEach(([query, value]) => mockClient.setRequestHandler(query, value)); diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js index 1a88e80344e..2d560c43fa5 100644 --- a/spec/frontend/helpers/startup_css_helper_spec.js +++ b/spec/frontend/helpers/startup_css_helper_spec.js @@ -19,6 +19,26 @@ describe('waitForCSSLoaded', () => { }); }); + describe('when gon features is not provided', () => { + let originalGon; + + beforeEach(() => { + originalGon = window.gon; + window.gon = null; + }); + + afterEach(() => { + window.gon = originalGon; + }); + + it('should invoke the action right away', async () => { + const events = waitForCSSLoaded(mockedCallback); + await events; + + expect(mockedCallback).toHaveBeenCalledTimes(1); + }); + }); + describe('with startup css disabled', () => { gon.features = { startupCss: false, diff --git a/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js deleted file mode 100644 index 42e0a20bc7b..00000000000 --- a/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js +++ /dev/null @@ -1,75 +0,0 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { createStore } from '~/ide/stores'; -import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue'; -import { file } from '../../helpers'; -import { removeWhitespace } from '../../../helpers/text_helper'; - -describe('Multi-file editor commit sidebar list collapsed', () => { - let vm; - let store; - - beforeEach(() => { - store = createStore(); - - const Component = Vue.extend(listCollapsed); - - vm = createComponentWithStore(Component, store, { - files: [ - { - ...file('file1'), - tempFile: true, - }, - file('file2'), - ], - iconName: 'staged', - title: 'Staged', - }); - - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('renders added & modified files count', () => { - expect(removeWhitespace(vm.$el.textContent).trim()).toBe('1 1'); - }); - - describe('addedFilesLength', () => { - it('returns an length of temp files', () => { - expect(vm.addedFilesLength).toBe(1); - }); - }); - - describe('modifiedFilesLength', () => { - it('returns an length of modified files', () => { - expect(vm.modifiedFilesLength).toBe(1); - }); - }); - - describe('addedFilesIconClass', () => { - it('includes multi-file-addition when addedFiles is not empty', () => { - expect(vm.addedFilesIconClass).toContain('multi-file-addition'); - }); - - it('excludes multi-file-addition when addedFiles is empty', () => { - vm.files = []; - - expect(vm.addedFilesIconClass).not.toContain('multi-file-addition'); - }); - }); - - describe('modifiedFilesClass', () => { - it('includes multi-file-modified when addedFiles is not empty', () => { - expect(vm.modifiedFilesClass).toContain('multi-file-modified'); - }); - - it('excludes multi-file-modified when addedFiles is empty', () => { - vm.files = []; - - expect(vm.modifiedFilesClass).not.toContain('multi-file-modified'); - }); - }); -}); diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js index 2107ff96e95..636dfbf0b2a 100644 --- a/spec/frontend/ide/components/commit_sidebar/list_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js @@ -16,7 +16,6 @@ describe('Multi-file editor commit sidebar list', () => { vm = createComponentWithStore(Component, store, { title: 'Staged', fileList: [], - iconName: 'staged', action: 'stageAllChanges', actionBtnText: 'stage all', actionBtnIcon: 'history', diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js index 86e4e8d8f89..72e9463945b 100644 --- a/spec/frontend/ide/components/ide_side_bar_spec.js +++ b/spec/frontend/ide/components/ide_side_bar_spec.js @@ -1,10 +1,12 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { GlSkeletonLoading } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import { createStore } from '~/ide/stores'; import IdeSidebar from '~/ide/components/ide_side_bar.vue'; import IdeTree from '~/ide/components/ide_tree.vue'; import RepoCommitSection from '~/ide/components/repo_commit_section.vue'; +import IdeReview from '~/ide/components/ide_review.vue'; import { leftSidebarViews } from '~/ide/constants'; import { projectData } from '../mock_data'; @@ -15,11 +17,12 @@ describe('IdeSidebar', () => { let wrapper; let store; - function createComponent() { + function createComponent({ view = leftSidebarViews.edit.name } = {}) { store = createStore(); store.state.currentProjectId = 'abcproject'; store.state.projects.abcproject = projectData; + store.state.currentActivityView = view; return mount(IdeSidebar, { store, @@ -48,22 +51,46 @@ describe('IdeSidebar', () => { expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(3); }); - describe('activityBarComponent', () => { - it('renders tree component', () => { + describe('deferred rendering components', () => { + it('fetches components on demand', async () => { wrapper = createComponent(); expect(wrapper.find(IdeTree).exists()).toBe(true); - }); + expect(wrapper.find(IdeReview).exists()).toBe(false); + expect(wrapper.find(RepoCommitSection).exists()).toBe(false); - it('renders commit component', async () => { - wrapper = createComponent(); + store.state.currentActivityView = leftSidebarViews.review.name; + await waitForPromises(); + await wrapper.vm.$nextTick(); - store.state.currentActivityView = leftSidebarViews.commit.name; + expect(wrapper.find(IdeTree).exists()).toBe(false); + expect(wrapper.find(IdeReview).exists()).toBe(true); + expect(wrapper.find(RepoCommitSection).exists()).toBe(false); + store.state.currentActivityView = leftSidebarViews.commit.name; + await waitForPromises(); await wrapper.vm.$nextTick(); + expect(wrapper.find(IdeTree).exists()).toBe(false); + expect(wrapper.find(IdeReview).exists()).toBe(false); expect(wrapper.find(RepoCommitSection).exists()).toBe(true); }); + it.each` + view | tree | review | commit + ${leftSidebarViews.edit.name} | ${true} | ${false} | ${false} + ${leftSidebarViews.review.name} | ${false} | ${true} | ${false} + ${leftSidebarViews.commit.name} | ${false} | ${false} | ${true} + `('renders correct panels for $view', async ({ view, tree, review, commit } = {}) => { + wrapper = createComponent({ + view, + }); + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(IdeTree).exists()).toBe(tree); + expect(wrapper.find(IdeReview).exists()).toBe(review); + expect(wrapper.find(RepoCommitSection).exists()).toBe(commit); + }); }); it('keeps the current activity view components alive', async () => { @@ -72,7 +99,7 @@ describe('IdeSidebar', () => { const ideTreeComponent = wrapper.find(IdeTree).element; store.state.currentActivityView = leftSidebarViews.commit.name; - + await waitForPromises(); await wrapper.vm.$nextTick(); expect(wrapper.find(IdeTree).exists()).toBe(false); @@ -80,6 +107,7 @@ describe('IdeSidebar', () => { store.state.currentActivityView = leftSidebarViews.edit.name; + await waitForPromises(); await wrapper.vm.$nextTick(); // reference to the elements remains the same, meaning the components were kept alive diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index a7b07a9f0e2..ff3852b6775 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -1,127 +1,165 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { createStore } from '~/ide/stores'; +import ErrorMessage from '~/ide/components/error_message.vue'; +import FindFile from '~/vue_shared/components/file_finder/index.vue'; +import CommitEditorHeader from '~/ide/components/commit_sidebar/editor_header.vue'; +import RepoTabs from '~/ide/components/repo_tabs.vue'; +import IdeStatusBar from '~/ide/components/ide_status_bar.vue'; +import RightPane from '~/ide/components/panes/right.vue'; +import NewModal from '~/ide/components/new_dropdown/modal.vue'; + import ide from '~/ide/components/ide.vue'; import { file } from '../helpers'; import { projectData } from '../mock_data'; -import extendStore from '~/ide/stores/extend'; - -let store; -function bootstrap(projData) { - store = createStore(); +const localVue = createLocalVue(); +localVue.use(Vuex); - extendStore(store, document.createElement('div')); +describe('WebIDE', () => { + const emptyProjData = { ...projectData, empty_repo: true, branches: {} }; - const Component = Vue.extend(ide); + let wrapper; - store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; - store.state.projects.abcproject = { ...projData }; - Vue.set(store.state.trees, 'abcproject/master', { - tree: [], - loading: false, - }); - - return createComponentWithStore(Component, store, { - emptyStateSvgPath: 'svg', - noChangesStateSvgPath: 'svg', - committedStateSvgPath: 'svg', - }); -} + function createComponent({ projData = emptyProjData, state = {} } = {}) { + const store = createStore(); -describe('ide component, empty repo', () => { - let vm; + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { ...projData }; + store.state.trees['abcproject/master'] = { + tree: [], + loading: false, + }; + Object.keys(state).forEach(key => { + store.state[key] = state[key]; + }); - beforeEach(() => { - const emptyProjData = { ...projectData, empty_repo: true, branches: {} }; - vm = bootstrap(emptyProjData); - vm.$mount(); - }); + return shallowMount(ide, { + store, + localVue, + stubs: { + ErrorMessage, + GlButton, + GlLoadingIcon, + CommitEditorHeader, + RepoTabs, + IdeStatusBar, + FindFile, + RightPane, + NewModal, + }, + }); + } afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); - it('renders "New file" button in empty repo', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).not.toBeNull(); - done(); + describe('ide component, empty repo', () => { + beforeEach(() => { + wrapper = createComponent({ + projData: { + empty_repo: true, + }, + }); }); - }); -}); - -describe('ide component, non-empty repo', () => { - let vm; - beforeEach(() => { - vm = bootstrap(projectData); - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); + it('renders "New file" button in empty repo', async () => { + expect(wrapper.find('[title="New file"]').exists()).toBe(true); + }); }); - it('shows error message when set', done => { - expect(vm.$el.querySelector('.gl-alert')).toBe(null); + describe('ide component, non-empty repo', () => { + describe('error message', () => { + it('does not show error message when it is not set', () => { + wrapper = createComponent({ + state: { + errorMessage: null, + }, + }); - vm.$store.state.errorMessage = { - text: 'error', - }; + expect(wrapper.find(ErrorMessage).exists()).toBe(false); + }); - vm.$nextTick(() => { - expect(vm.$el.querySelector('.gl-alert')).not.toBe(null); + it('shows error message when set', () => { + wrapper = createComponent({ + state: { + errorMessage: { + text: 'error', + }, + }, + }); - done(); + expect(wrapper.find(ErrorMessage).exists()).toBe(true); + }); }); - }); - describe('onBeforeUnload', () => { - it('returns undefined when no staged files or changed files', () => { - expect(vm.onBeforeUnload()).toBe(undefined); - }); + describe('onBeforeUnload', () => { + it('returns undefined when no staged files or changed files', () => { + wrapper = createComponent(); + expect(wrapper.vm.onBeforeUnload()).toBe(undefined); + }); - it('returns warning text when their are changed files', () => { - vm.$store.state.changedFiles.push(file()); + it('returns warning text when their are changed files', () => { + wrapper = createComponent({ + state: { + changedFiles: [file()], + }, + }); - expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?'); - }); + expect(wrapper.vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?'); + }); - it('returns warning text when their are staged files', () => { - vm.$store.state.stagedFiles.push(file()); + it('returns warning text when their are staged files', () => { + wrapper = createComponent({ + state: { + stagedFiles: [file()], + }, + }); - expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?'); - }); + expect(wrapper.vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?'); + }); - it('updates event object', () => { - const event = {}; - vm.$store.state.stagedFiles.push(file()); + it('updates event object', () => { + const event = {}; + wrapper = createComponent({ + state: { + stagedFiles: [file()], + }, + }); - vm.onBeforeUnload(event); + wrapper.vm.onBeforeUnload(event); - expect(event.returnValue).toBe('Are you sure you want to lose unsaved changes?'); + expect(event.returnValue).toBe('Are you sure you want to lose unsaved changes?'); + }); }); - }); - describe('non-existent branch', () => { - it('does not render "New file" button for non-existent branch when repo is not empty', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull(); - done(); + describe('non-existent branch', () => { + it('does not render "New file" button for non-existent branch when repo is not empty', () => { + wrapper = createComponent({ + state: { + projects: {}, + }, + }); + + expect(wrapper.find('[title="New file"]').exists()).toBe(false); }); }); - }); - describe('branch with files', () => { - beforeEach(() => { - store.state.trees['abcproject/master'].tree = [file()]; - }); + describe('branch with files', () => { + beforeEach(() => { + wrapper = createComponent({ + projData: { + empty_repo: false, + }, + }); + }); - it('does not render "New file" button', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull(); - done(); + it('does not render "New file" button', () => { + expect(wrapper.find('[title="New file"]').exists()).toBe(false); }); }); }); diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js index bb8165d1a52..02b5dc19bd8 100644 --- a/spec/frontend/ide/components/ide_status_list_spec.js +++ b/spec/frontend/ide/components/ide_status_list_spec.js @@ -6,17 +6,21 @@ import TerminalSyncStatusSafe from '~/ide/components/terminal_sync/terminal_sync const TEST_FILE = { name: 'lorem.md', - editorRow: 3, - editorColumn: 23, - fileLanguage: 'markdown', content: 'abc\nndef', permalink: '/lorem.md', }; +const TEST_FILE_EDITOR = { + fileLanguage: 'markdown', + editorRow: 3, + editorColumn: 23, +}; +const TEST_EDITOR_POSITION = `${TEST_FILE_EDITOR.editorRow}:${TEST_FILE_EDITOR.editorColumn}`; const localVue = createLocalVue(); localVue.use(Vuex); describe('ide/components/ide_status_list', () => { + let activeFileEditor; let activeFile; let store; let wrapper; @@ -27,6 +31,14 @@ describe('ide/components/ide_status_list', () => { getters: { activeFile: () => activeFile, }, + modules: { + editor: { + namespaced: true, + getters: { + activeFileEditor: () => activeFileEditor, + }, + }, + }, }); wrapper = shallowMount(IdeStatusList, { @@ -38,6 +50,7 @@ describe('ide/components/ide_status_list', () => { beforeEach(() => { activeFile = TEST_FILE; + activeFileEditor = TEST_FILE_EDITOR; }); afterEach(() => { @@ -47,8 +60,6 @@ describe('ide/components/ide_status_list', () => { wrapper = null; }); - const getEditorPosition = file => `${file.editorRow}:${file.editorColumn}`; - describe('with regular file', () => { beforeEach(() => { createComponent(); @@ -65,11 +76,11 @@ describe('ide/components/ide_status_list', () => { }); it('shows file editor position', () => { - expect(wrapper.text()).toContain(getEditorPosition(TEST_FILE)); + expect(wrapper.text()).toContain(TEST_EDITOR_POSITION); }); it('shows file language', () => { - expect(wrapper.text()).toContain(TEST_FILE.fileLanguage); + expect(wrapper.text()).toContain(TEST_FILE_EDITOR.fileLanguage); }); }); @@ -81,7 +92,7 @@ describe('ide/components/ide_status_list', () => { }); it('does not show file editor position', () => { - expect(wrapper.text()).not.toContain(getEditorPosition(TEST_FILE)); + expect(wrapper.text()).not.toContain(TEST_EDITOR_POSITION); }); }); diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index 7f083fa7c25..c1744fefe20 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -1,11 +1,10 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlTab } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import { pipelines } from 'jest/ide/mock_data'; import List from '~/ide/components/pipelines/list.vue'; import JobsList from '~/ide/components/jobs/list.vue'; -import Tab from '~/vue_shared/components/tabs/tab.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import IDEServices from '~/ide/services'; @@ -167,7 +166,7 @@ describe('IDE pipelines list', () => { createComponent({}, { ...withLatestPipelineState, stages, isLoadingJobs }); const jobProps = wrapper - .findAll(Tab) + .findAll(GlTab) .at(0) .find(JobsList) .props(); @@ -182,7 +181,7 @@ describe('IDE pipelines list', () => { createComponent({}, { ...withLatestPipelineState, isLoadingJobs }); const jobProps = wrapper - .findAll(Tab) + .findAll(GlTab) .at(1) .find(JobsList) .props(); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 9f4c9c1622a..71a4f08cfb4 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -55,7 +55,6 @@ describe('RepoEditor', () => { beforeEach(() => { const f = { ...file('file.txt'), - viewMode: FILE_VIEW_MODE_EDITOR, content: 'hello world', }; @@ -92,6 +91,8 @@ describe('RepoEditor', () => { }); const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder'); + const changeViewMode = viewMode => + store.dispatch('editor/updateFileEditor', { path: vm.file.path, data: { viewMode } }); describe('default', () => { beforeEach(() => { @@ -409,7 +410,7 @@ describe('RepoEditor', () => { describe('when files view mode is preview', () => { beforeEach(done => { jest.spyOn(vm.editor, 'updateDimensions').mockImplementation(); - vm.file.viewMode = FILE_VIEW_MODE_PREVIEW; + changeViewMode(FILE_VIEW_MODE_PREVIEW); vm.file.name = 'myfile.md'; vm.file.content = 'hello world'; @@ -423,7 +424,7 @@ describe('RepoEditor', () => { describe('when file view mode changes to editor', () => { it('should update dimensions', () => { - vm.file.viewMode = FILE_VIEW_MODE_EDITOR; + changeViewMode(FILE_VIEW_MODE_EDITOR); return vm.$nextTick().then(() => { expect(vm.editor.updateDimensions).toHaveBeenCalled(); diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js index f35726de27c..a44c8b4d5ee 100644 --- a/spec/frontend/ide/components/repo_tab_spec.js +++ b/spec/frontend/ide/components/repo_tab_spec.js @@ -100,6 +100,18 @@ describe('RepoTab', () => { expect(wrapper.find('.file-modified').exists()).toBe(true); }); + it.each` + tabProps | closeLabel + ${{}} | ${'Close foo.txt'} + ${{ changed: true }} | ${'foo.txt changed'} + `('close button has label ($closeLabel) with tab ($tabProps)', ({ tabProps, closeLabel }) => { + const tab = { ...file('foo.txt'), ...tabProps }; + + createComponent({ tab }); + + expect(wrapper.find('button').attributes('aria-label')).toBe(closeLabel); + }); + describe('locked file', () => { let f; @@ -122,9 +134,7 @@ describe('RepoTab', () => { }); it('renders a tooltip', () => { - expect(wrapper.find('span:nth-child(2)').attributes('data-original-title')).toContain( - 'Locked by testuser', - ); + expect(wrapper.find('span:nth-child(2)').attributes('title')).toBe('Locked by testuser'); }); }); diff --git a/spec/frontend/ide/components/terminal/empty_state_spec.js b/spec/frontend/ide/components/terminal/empty_state_spec.js index a3f2089608d..b62470f67b6 100644 --- a/spec/frontend/ide/components/terminal/empty_state_spec.js +++ b/spec/frontend/ide/components/terminal/empty_state_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; import { TEST_HOST } from 'spec/test_constants'; import TerminalEmptyState from '~/ide/components/terminal/empty_state.vue'; @@ -36,7 +36,7 @@ describe('IDE TerminalEmptyState', () => { const img = wrapper.find('.svg-content img'); expect(img.exists()).toBe(true); - expect(img.attributes('src')).toEqual(TEST_PATH); + expect(img.attributes('src')).toBe(TEST_PATH); }); it('when loading, shows loading icon', () => { @@ -71,24 +71,23 @@ describe('IDE TerminalEmptyState', () => { }, }); - button = wrapper.find('button'); + button = wrapper.find(GlButton); }); it('shows button', () => { - expect(button.text()).toEqual('Start Web Terminal'); - expect(button.attributes('disabled')).toBeFalsy(); + expect(button.text()).toBe('Start Web Terminal'); + expect(button.props('disabled')).toBe(false); }); it('emits start when button is clicked', () => { - expect(wrapper.emitted().start).toBeFalsy(); - - button.trigger('click'); + expect(wrapper.emitted().start).toBeUndefined(); + button.vm.$emit('click'); expect(wrapper.emitted().start).toHaveLength(1); }); it('shows help path link', () => { - expect(wrapper.find('a').attributes('href')).toEqual(TEST_HELP_PATH); + expect(wrapper.find('a').attributes('href')).toBe(TEST_HELP_PATH); }); }); @@ -101,7 +100,7 @@ describe('IDE TerminalEmptyState', () => { }, }); - expect(wrapper.find('button').attributes('disabled')).not.toBe(null); - expect(wrapper.find('.bs-callout').element.innerHTML).toEqual(TEST_HTML_MESSAGE); + expect(wrapper.find(GlButton).props('disabled')).toBe(true); + expect(wrapper.find(GlAlert).html()).toContain(TEST_HTML_MESSAGE); }); }); diff --git a/spec/frontend/ide/helpers.js b/spec/frontend/ide/helpers.js index 0e85b523cbd..6b65dd96ef4 100644 --- a/spec/frontend/ide/helpers.js +++ b/spec/frontend/ide/helpers.js @@ -1,5 +1,6 @@ import * as pathUtils from 'path'; import { decorateData } from '~/ide/stores/utils'; +import { commitActionTypes } from '~/ide/constants'; export const file = (name = 'name', id = name, type = '', parent = null) => decorateData({ @@ -28,3 +29,17 @@ export const createEntriesFromPaths = paths => ...entries, }; }, {}); + +export const createTriggerChangeAction = payload => ({ + type: 'triggerFilesChange', + ...(payload ? { payload } : {}), +}); + +export const createTriggerRenamePayload = (path, newPath) => ({ + type: commitActionTypes.move, + path, + newPath, +}); + +export const createTriggerRenameAction = (path, newPath) => + createTriggerChangeAction(createTriggerRenamePayload(path, newPath)); diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index 8f7fcc25cf0..cc290fc526e 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -7,7 +7,7 @@ import * as types from '~/ide/stores/mutation_types'; import service from '~/ide/services'; import { createRouter } from '~/ide/ide_router'; import eventHub from '~/ide/eventhub'; -import { file } from '../../helpers'; +import { file, createTriggerRenameAction } from '../../helpers'; const ORIGINAL_CONTENT = 'original content'; const RELATIVE_URL_ROOT = '/gitlab'; @@ -785,13 +785,19 @@ describe('IDE store file actions', () => { }); describe('triggerFilesChange', () => { + const { payload: renamePayload } = createTriggerRenameAction('test', '123'); + beforeEach(() => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); - it('emits event that files have changed', () => { - return store.dispatch('triggerFilesChange').then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change'); + it.each` + args | payload + ${[]} | ${{}} + ${[renamePayload]} | ${renamePayload} + `('emits event that files have changed (args=$args)', ({ args, payload }) => { + return store.dispatch('triggerFilesChange', ...args).then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change', payload); }); }); }); diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index ebf39df2f6f..04128c27e70 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -19,7 +19,7 @@ import { } from '~/ide/stores/actions'; import axios from '~/lib/utils/axios_utils'; import * as types from '~/ide/stores/mutation_types'; -import { file } from '../helpers'; +import { file, createTriggerRenameAction, createTriggerChangeAction } from '../helpers'; import testAction from '../../helpers/vuex_action_helper'; import eventHub from '~/ide/eventhub'; @@ -522,7 +522,7 @@ describe('Multi-file store actions', () => { 'path', store.state, [{ type: types.DELETE_ENTRY, payload: 'path' }], - [{ type: 'stageChange', payload: 'path' }, { type: 'triggerFilesChange' }], + [{ type: 'stageChange', payload: 'path' }, createTriggerChangeAction()], done, ); }); @@ -551,7 +551,7 @@ describe('Multi-file store actions', () => { [{ type: types.DELETE_ENTRY, payload: 'testFolder/entry-to-delete' }], [ { type: 'stageChange', payload: 'testFolder/entry-to-delete' }, - { type: 'triggerFilesChange' }, + createTriggerChangeAction(), ], done, ); @@ -614,7 +614,7 @@ describe('Multi-file store actions', () => { testEntry.path, store.state, [{ type: types.DELETE_ENTRY, payload: testEntry.path }], - [{ type: 'stageChange', payload: testEntry.path }, { type: 'triggerFilesChange' }], + [{ type: 'stageChange', payload: testEntry.path }, createTriggerChangeAction()], done, ); }); @@ -754,7 +754,7 @@ describe('Multi-file store actions', () => { payload: origEntry, }, ], - [{ type: 'triggerFilesChange' }], + [createTriggerRenameAction('renamed', 'orig')], done, ); }); @@ -767,7 +767,7 @@ describe('Multi-file store actions', () => { { path: 'orig', name: 'renamed' }, store.state, [expect.objectContaining({ type: types.RENAME_ENTRY })], - [{ type: 'triggerFilesChange' }], + [createTriggerRenameAction('orig', 'renamed')], done, ); }); diff --git a/spec/frontend/ide/stores/modules/editor/actions_spec.js b/spec/frontend/ide/stores/modules/editor/actions_spec.js new file mode 100644 index 00000000000..6a420ac32de --- /dev/null +++ b/spec/frontend/ide/stores/modules/editor/actions_spec.js @@ -0,0 +1,36 @@ +import testAction from 'helpers/vuex_action_helper'; +import * as types from '~/ide/stores/modules/editor/mutation_types'; +import * as actions from '~/ide/stores/modules/editor/actions'; +import { createTriggerRenamePayload } from '../../../helpers'; + +describe('~/ide/stores/modules/editor/actions', () => { + describe('updateFileEditor', () => { + it('commits with payload', () => { + const payload = {}; + + testAction(actions.updateFileEditor, payload, {}, [ + { type: types.UPDATE_FILE_EDITOR, payload }, + ]); + }); + }); + + describe('removeFileEditor', () => { + it('commits with payload', () => { + const payload = 'path/to/file.txt'; + + testAction(actions.removeFileEditor, payload, {}, [ + { type: types.REMOVE_FILE_EDITOR, payload }, + ]); + }); + }); + + describe('renameFileEditor', () => { + it('commits with payload', () => { + const payload = createTriggerRenamePayload('test', 'test123'); + + testAction(actions.renameFileEditor, payload, {}, [ + { type: types.RENAME_FILE_EDITOR, payload }, + ]); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/editor/getters_spec.js b/spec/frontend/ide/stores/modules/editor/getters_spec.js new file mode 100644 index 00000000000..55e1e31f66f --- /dev/null +++ b/spec/frontend/ide/stores/modules/editor/getters_spec.js @@ -0,0 +1,31 @@ +import { createDefaultFileEditor } from '~/ide/stores/modules/editor/utils'; +import * as getters from '~/ide/stores/modules/editor/getters'; + +const TEST_PATH = 'test/path.md'; +const TEST_FILE_EDITOR = { + ...createDefaultFileEditor(), + editorRow: 7, + editorColumn: 8, + fileLanguage: 'markdown', +}; + +describe('~/ide/stores/modules/editor/getters', () => { + describe('activeFileEditor', () => { + it.each` + activeFile | fileEditors | expected + ${null} | ${{}} | ${null} + ${{}} | ${{}} | ${createDefaultFileEditor()} + ${{ path: TEST_PATH }} | ${{}} | ${createDefaultFileEditor()} + ${{ path: TEST_PATH }} | ${{ bogus: createDefaultFileEditor(), [TEST_PATH]: TEST_FILE_EDITOR }} | ${TEST_FILE_EDITOR} + `( + 'with activeFile=$activeFile and fileEditors=$fileEditors', + ({ activeFile, fileEditors, expected }) => { + const rootGetters = { activeFile }; + const state = { fileEditors }; + const result = getters.activeFileEditor(state, {}, {}, rootGetters); + + expect(result).toEqual(expected); + }, + ); + }); +}); diff --git a/spec/frontend/ide/stores/modules/editor/mutations_spec.js b/spec/frontend/ide/stores/modules/editor/mutations_spec.js new file mode 100644 index 00000000000..e4b330b3174 --- /dev/null +++ b/spec/frontend/ide/stores/modules/editor/mutations_spec.js @@ -0,0 +1,78 @@ +import { createDefaultFileEditor } from '~/ide/stores/modules/editor/utils'; +import * as types from '~/ide/stores/modules/editor/mutation_types'; +import mutations from '~/ide/stores/modules/editor/mutations'; +import { createTriggerRenamePayload } from '../../../helpers'; + +const TEST_PATH = 'test/path.md'; + +describe('~/ide/stores/modules/editor/mutations', () => { + describe(types.UPDATE_FILE_EDITOR, () => { + it('with path that does not exist, should initialize with default values', () => { + const state = { fileEditors: {} }; + const data = { fileLanguage: 'markdown' }; + + mutations[types.UPDATE_FILE_EDITOR](state, { path: TEST_PATH, data }); + + expect(state.fileEditors).toEqual({ + [TEST_PATH]: { + ...createDefaultFileEditor(), + ...data, + }, + }); + }); + + it('with existing path, should overwrite values', () => { + const state = { + fileEditors: { + foo: {}, + [TEST_PATH]: { ...createDefaultFileEditor(), editorRow: 7, editorColumn: 7 }, + }, + }; + + mutations[types.UPDATE_FILE_EDITOR](state, { + path: TEST_PATH, + data: { fileLanguage: 'markdown' }, + }); + + expect(state).toEqual({ + fileEditors: { + foo: {}, + [TEST_PATH]: { + ...createDefaultFileEditor(), + editorRow: 7, + editorColumn: 7, + fileLanguage: 'markdown', + }, + }, + }); + }); + }); + + describe(types.REMOVE_FILE_EDITOR, () => { + it.each` + fileEditors | path | expected + ${{}} | ${'does/not/exist.txt'} | ${{}} + ${{ foo: {}, [TEST_PATH]: {} }} | ${TEST_PATH} | ${{ foo: {} }} + `('removes file $path', ({ fileEditors, path, expected }) => { + const state = { fileEditors }; + + mutations[types.REMOVE_FILE_EDITOR](state, path); + + expect(state).toEqual({ fileEditors: expected }); + }); + }); + + describe(types.RENAME_FILE_EDITOR, () => { + it.each` + fileEditors | payload | expected + ${{ foo: {} }} | ${createTriggerRenamePayload('does/not/exist', 'abc')} | ${{ foo: {} }} + ${{ foo: { a: 1 }, bar: {} }} | ${createTriggerRenamePayload('foo', 'abc/def')} | ${{ 'abc/def': { a: 1 }, bar: {} }} + `('renames fileEditor at $payload', ({ fileEditors, payload, expected }) => { + const state = { fileEditors }; + + mutations[types.RENAME_FILE_EDITOR](state, payload); + + expect(state).toEqual({ fileEditors: expected }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/editor/setup_spec.js b/spec/frontend/ide/stores/modules/editor/setup_spec.js new file mode 100644 index 00000000000..71b5d7590c5 --- /dev/null +++ b/spec/frontend/ide/stores/modules/editor/setup_spec.js @@ -0,0 +1,44 @@ +import Vuex from 'vuex'; +import eventHub from '~/ide/eventhub'; +import { createStoreOptions } from '~/ide/stores'; +import { setupFileEditorsSync } from '~/ide/stores/modules/editor/setup'; +import { createTriggerRenamePayload } from '../../../helpers'; + +describe('~/ide/stores/modules/editor/setup', () => { + let store; + + beforeEach(() => { + store = new Vuex.Store(createStoreOptions()); + store.state.entries = { + foo: {}, + bar: {}, + }; + store.state.editor.fileEditors = { + foo: {}, + bizz: {}, + }; + + setupFileEditorsSync(store); + }); + + it('when files change is emitted, removes unused fileEditors', () => { + eventHub.$emit('ide.files.change'); + + expect(store.state.entries).toEqual({ + foo: {}, + bar: {}, + }); + expect(store.state.editor.fileEditors).toEqual({ + foo: {}, + }); + }); + + it('when files rename is emitted, renames fileEditor', () => { + eventHub.$emit('ide.files.change', createTriggerRenamePayload('foo', 'foo_new')); + + expect(store.state.editor.fileEditors).toEqual({ + foo_new: {}, + bizz: {}, + }); + }); +}); diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js index d303de6e9ef..fd39cf21635 100644 --- a/spec/frontend/ide/stores/mutations/file_spec.js +++ b/spec/frontend/ide/stores/mutations/file_spec.js @@ -1,6 +1,5 @@ import mutations from '~/ide/stores/mutations/file'; import { createStore } from '~/ide/stores'; -import { FILE_VIEW_MODE_PREVIEW } from '~/ide/constants'; import { file } from '../../helpers'; describe('IDE store file mutations', () => { @@ -532,17 +531,6 @@ describe('IDE store file mutations', () => { }); }); - describe('SET_FILE_VIEWMODE', () => { - it('updates file view mode', () => { - mutations.SET_FILE_VIEWMODE(localState, { - file: localFile, - viewMode: FILE_VIEW_MODE_PREVIEW, - }); - - expect(localFile.viewMode).toBe(FILE_VIEW_MODE_PREVIEW); - }); - }); - describe('ADD_PENDING_TAB', () => { beforeEach(() => { const f = { ...file('openFile'), path: 'openFile', active: true, opened: true }; diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js index 1dbad588ec4..7322c7c1ae1 100644 --- a/spec/frontend/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_projects/components/import_projects_table_spec.js @@ -33,7 +33,6 @@ describe('ImportProjectsTable', () => { const importAllFn = jest.fn(); const importAllModalShowFn = jest.fn(); - const setPageFn = jest.fn(); const fetchReposFn = jest.fn(); function createComponent({ @@ -60,7 +59,6 @@ describe('ImportProjectsTable', () => { stopJobsPolling: jest.fn(), clearJobsEtagPoll: jest.fn(), setFilter: jest.fn(), - setPage: setPageFn, }, }); diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js index 6951f2bf04d..06afb20c6a2 100644 --- a/spec/frontend/import_projects/store/actions_spec.js +++ b/spec/frontend/import_projects/store/actions_spec.js @@ -16,6 +16,7 @@ import { RECEIVE_NAMESPACES_SUCCESS, RECEIVE_NAMESPACES_ERROR, SET_PAGE, + SET_FILTER, } from '~/import_projects/store/mutation_types'; import actionsFactory from '~/import_projects/store/actions'; import { getImportTarget } from '~/import_projects/store/getters'; @@ -40,7 +41,7 @@ const { fetchImport, fetchJobs, fetchNamespaces, - setPage, + setFilter, } = actionsFactory({ endpoints, }); @@ -68,6 +69,7 @@ describe('import_projects store actions', () => { importStatus: STATUSES.NONE, }, ], + provider: 'provider', }; localState.getImportTarget = getImportTarget(localState); @@ -149,7 +151,28 @@ describe('import_projects store actions', () => { ); }); - describe('when /home/xanf/projects/gdk/gitlab/spec/frontend/import_projects/store/actions_spec.jsfiltered', () => { + describe('when rate limited', () => { + it('commits RECEIVE_REPOS_ERROR and shows rate limited error message', async () => { + mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(429); + + await testAction( + fetchRepos, + null, + { ...localState, filter: 'filter' }, + [ + { type: SET_PAGE, payload: 1 }, + { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 0 }, + { type: RECEIVE_REPOS_ERROR }, + ], + [], + ); + + expect(createFlash).toHaveBeenCalledWith('Provider rate limit exceeded. Try again later'); + }); + }); + + describe('when filtered', () => { it('fetches repos with filter applied', () => { mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload); @@ -359,21 +382,17 @@ describe('import_projects store actions', () => { ], ); }); + }); - describe('setPage', () => { - it('dispatches fetchRepos and commits setPage when page number differs from current one', async () => { - await testAction( - setPage, - 2, - { ...localState, pageInfo: { page: 1 } }, - [{ type: SET_PAGE, payload: 2 }], - [{ type: 'fetchRepos' }], - ); - }); - - it('does not perform any action if page equals to current one', async () => { - await testAction(setPage, 2, { ...localState, pageInfo: { page: 2 } }, [], []); - }); + describe('setFilter', () => { + it('dispatches sets the filter value and dispatches fetchRepos', async () => { + await testAction( + setFilter, + 'filteredRepo', + localState, + [{ type: SET_FILTER, payload: 'filteredRepo' }], + [{ type: 'fetchRepos' }], + ); }); }); }); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 709f66bb352..6329a84ff6e 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -10,6 +10,7 @@ import { TH_CREATED_AT_TEST_ID, TH_SEVERITY_TEST_ID, TH_PUBLISHED_TEST_ID, + TH_INCIDENT_SLA_TEST_ID, trackIncidentCreateNewOptions, trackIncidentListViewsOptions, } from '~/incidents/constants'; @@ -277,10 +278,11 @@ describe('Incidents List', () => { const noneSort = 'none'; it.each` - selector | initialSort | firstSort | nextSort - ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} - ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} - ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + selector | initialSort | firstSort | nextSort + ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} + ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort} `('updates sort with new direction', async ({ selector, initialSort, firstSort, nextSort }) => { const [[attr, value]] = Object.entries(selector); const columnHeader = () => wrapper.find(`[${attr}="${value}"]`); diff --git a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js index 02f311f579f..b570ab4e844 100644 --- a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js +++ b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js @@ -34,7 +34,7 @@ describe('ConfirmationModal', () => { 'Saving will update the default settings for all projects that are not using custom settings.', ); expect(findGlModal().text()).toContain( - 'Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.', + 'Projects using custom settings will not be impacted unless the project owner chooses to use parent level defaults.', ); }); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index efcc727277a..97e77ac87ab 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -5,10 +5,12 @@ import IntegrationForm from '~/integrations/edit/components/integration_form.vue import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; +import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; +import { integrationLevels } from '~/integrations/edit/constants'; describe('IntegrationForm', () => { let wrapper; @@ -43,6 +45,8 @@ describe('IntegrationForm', () => { const findOverrideDropdown = () => wrapper.find(OverrideDropdown); const findActiveCheckbox = () => wrapper.find(ActiveCheckbox); const findConfirmationModal = () => wrapper.find(ConfirmationModal); + const findResetConfirmationModal = () => wrapper.find(ResetConfirmationModal); + const findResetButton = () => wrapper.find('[data-testid="reset-button"]'); const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields); const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields); const findTriggerFields = () => wrapper.find(TriggerFields); @@ -69,14 +73,70 @@ describe('IntegrationForm', () => { describe('integrationLevel is instance', () => { it('renders ConfirmationModal', () => { createComponent({ - integrationLevel: 'instance', + integrationLevel: integrationLevels.INSTANCE, }); expect(findConfirmationModal().exists()).toBe(true); }); + + describe('resetPath is empty', () => { + it('does not render ResetConfirmationModal and button', () => { + createComponent({ + integrationLevel: integrationLevels.INSTANCE, + }); + + expect(findResetButton().exists()).toBe(false); + expect(findResetConfirmationModal().exists()).toBe(false); + }); + }); + + describe('resetPath is present', () => { + it('renders ResetConfirmationModal and button', () => { + createComponent({ + integrationLevel: integrationLevels.INSTANCE, + resetPath: 'resetPath', + }); + + expect(findResetButton().exists()).toBe(true); + expect(findResetConfirmationModal().exists()).toBe(true); + }); + }); + }); + + describe('integrationLevel is group', () => { + it('renders ConfirmationModal', () => { + createComponent({ + integrationLevel: integrationLevels.GROUP, + }); + + expect(findConfirmationModal().exists()).toBe(true); + }); + + describe('resetPath is empty', () => { + it('does not render ResetConfirmationModal and button', () => { + createComponent({ + integrationLevel: integrationLevels.GROUP, + }); + + expect(findResetButton().exists()).toBe(false); + expect(findResetConfirmationModal().exists()).toBe(false); + }); + }); + + describe('resetPath is present', () => { + it('renders ResetConfirmationModal and button', () => { + createComponent({ + integrationLevel: integrationLevels.GROUP, + resetPath: 'resetPath', + }); + + expect(findResetButton().exists()).toBe(true); + expect(findResetConfirmationModal().exists()).toBe(true); + }); + }); }); - describe('integrationLevel is not instance', () => { + describe('integrationLevel is project', () => { it('does not render ConfirmationModal', () => { createComponent({ integrationLevel: 'project', @@ -84,6 +144,16 @@ describe('IntegrationForm', () => { expect(findConfirmationModal().exists()).toBe(false); }); + + it('does not render ResetConfirmationModal and button', () => { + createComponent({ + integrationLevel: 'project', + resetPath: 'resetPath', + }); + + expect(findResetButton().exists()).toBe(false); + expect(findResetConfirmationModal().exists()).toBe(false); + }); }); describe('type is "slack"', () => { diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js index 5356c0a411b..5b5c8d6f76e 100644 --- a/spec/frontend/integrations/edit/store/actions_spec.js +++ b/spec/frontend/integrations/edit/store/actions_spec.js @@ -1,6 +1,11 @@ import testAction from 'helpers/vuex_action_helper'; import createState from '~/integrations/edit/store/state'; -import { setOverride } from '~/integrations/edit/store/actions'; +import { + setOverride, + setIsSaving, + setIsTesting, + setIsResetting, +} from '~/integrations/edit/store/actions'; import * as types from '~/integrations/edit/store/mutation_types'; describe('Integration form store actions', () => { @@ -15,4 +20,24 @@ describe('Integration form store actions', () => { return testAction(setOverride, true, state, [{ type: types.SET_OVERRIDE, payload: true }]); }); }); + + describe('setIsSaving', () => { + it('should commit isSaving mutation', () => { + return testAction(setIsSaving, true, state, [{ type: types.SET_IS_SAVING, payload: true }]); + }); + }); + + describe('setIsTesting', () => { + it('should commit isTesting mutation', () => { + return testAction(setIsTesting, true, state, [{ type: types.SET_IS_TESTING, payload: true }]); + }); + }); + + describe('setIsResetting', () => { + it('should commit isResetting mutation', () => { + return testAction(setIsResetting, true, state, [ + { type: types.SET_IS_RESETTING, payload: true }, + ]); + }); + }); }); diff --git a/spec/frontend/integrations/edit/store/getters_spec.js b/spec/frontend/integrations/edit/store/getters_spec.js index 3353e0c84cc..7d4532a1059 100644 --- a/spec/frontend/integrations/edit/store/getters_spec.js +++ b/spec/frontend/integrations/edit/store/getters_spec.js @@ -1,5 +1,12 @@ -import { currentKey, isInheriting, propsSource } from '~/integrations/edit/store/getters'; +import { + currentKey, + isInheriting, + isDisabled, + propsSource, +} from '~/integrations/edit/store/getters'; import createState from '~/integrations/edit/store/state'; +import mutations from '~/integrations/edit/store/mutations'; +import * as types from '~/integrations/edit/store/mutation_types'; import { mockIntegrationProps } from '../mock_data'; describe('Integration form store getters', () => { @@ -45,6 +52,29 @@ describe('Integration form store getters', () => { }); }); + describe('isDisabled', () => { + it.each` + isSaving | isTesting | isResetting | expected + ${false} | ${false} | ${false} | ${false} + ${true} | ${false} | ${false} | ${true} + ${false} | ${true} | ${false} | ${true} + ${false} | ${false} | ${true} | ${true} + ${false} | ${true} | ${true} | ${true} + ${true} | ${false} | ${true} | ${true} + ${true} | ${true} | ${false} | ${true} + ${true} | ${true} | ${true} | ${true} + `( + 'when isSaving = $isSaving, isTesting = $isTesting, isResetting = $isResetting then isDisabled = $expected', + ({ isSaving, isTesting, isResetting, expected }) => { + mutations[types.SET_IS_SAVING](state, isSaving); + mutations[types.SET_IS_TESTING](state, isTesting); + mutations[types.SET_IS_RESETTING](state, isResetting); + + expect(isDisabled(state)).toBe(expected); + }, + ); + }); + describe('propsSource', () => { beforeEach(() => { state.defaultState = defaultState; diff --git a/spec/frontend/integrations/edit/store/mutations_spec.js b/spec/frontend/integrations/edit/store/mutations_spec.js index 4b733726d44..4707b4b3714 100644 --- a/spec/frontend/integrations/edit/store/mutations_spec.js +++ b/spec/frontend/integrations/edit/store/mutations_spec.js @@ -16,4 +16,28 @@ describe('Integration form store mutations', () => { expect(state.override).toBe(true); }); }); + + describe(`${types.SET_IS_SAVING}`, () => { + it('sets isSaving', () => { + mutations[types.SET_IS_SAVING](state, true); + + expect(state.isSaving).toBe(true); + }); + }); + + describe(`${types.SET_IS_TESTING}`, () => { + it('sets isTesting', () => { + mutations[types.SET_IS_TESTING](state, true); + + expect(state.isTesting).toBe(true); + }); + }); + + describe(`${types.SET_IS_RESETTING}`, () => { + it('sets isResetting', () => { + mutations[types.SET_IS_RESETTING](state, true); + + expect(state.isResetting).toBe(true); + }); + }); }); diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js index fc193850a94..4d0f4a1da71 100644 --- a/spec/frontend/integrations/edit/store/state_spec.js +++ b/spec/frontend/integrations/edit/store/state_spec.js @@ -7,6 +7,7 @@ describe('Integration form state factory', () => { customState: {}, isSaving: false, isTesting: false, + isResetting: false, override: false, }); }); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 0be0fbbde2d..4ac2a28105c 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -3,24 +3,31 @@ import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink } from '@gi import Api from '~/api'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; -const groupId = '1'; -const groupName = 'testgroup'; +const id = '1'; +const name = 'testgroup'; +const isProject = false; const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; const defaultAccessLevel = '10'; const helpLink = 'https://example.com'; -const createComponent = () => { +const createComponent = (data = {}) => { return shallowMount(InviteMembersModal, { propsData: { - groupId, - groupName, + id, + name, + isProject, accessLevels, defaultAccessLevel, helpLink, }, + data() { + return data; + }, stubs: { - GlSprintf, 'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>', + 'gl-dropdown': true, + 'gl-dropdown-item': true, + GlSprintf, }, }); }; @@ -34,7 +41,7 @@ describe('InviteMembersModal', () => { }); const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findDropdownItems = () => findDropdown().findAll(GlDropdownItem); const findDatepicker = () => wrapper.find(GlDatepicker); const findLink = () => wrapper.find(GlLink); const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); @@ -88,25 +95,69 @@ describe('InviteMembersModal', () => { format: 'json', }; - beforeEach(() => { - wrapper = createComponent(); + describe('when the invite was sent successfully', () => { + beforeEach(() => { + wrapper = createComponent(); + + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData }); - jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData }); - wrapper.vm.$toast = { show: jest.fn() }; + wrapper.vm.submitForm(postData); + }); - wrapper.vm.submitForm(postData); + it('displays the successful toastMessage', () => { + const toastMessageSuccessful = 'Members were successfully added'; + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( + toastMessageSuccessful, + wrapper.vm.toastOptions, + ); + }); + + it('calls Api inviteGroupMember with the correct params', () => { + expect(Api.inviteGroupMember).toHaveBeenCalledWith(id, postData); + }); }); - it('calls Api inviteGroupMember with the correct params', () => { - expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData); + describe('when sending the invite for a single member returned an api error', () => { + const apiErrorMessage = 'Members already exists'; + + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: '123' }); + + wrapper.vm.$toast = { show: jest.fn() }; + jest + .spyOn(Api, 'inviteGroupMember') + .mockRejectedValue({ response: { data: { message: apiErrorMessage } } }); + + findInviteButton().vm.$emit('click'); + }); + + it('displays the api error message for the toastMessage', () => { + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( + apiErrorMessage, + wrapper.vm.toastOptions, + ); + }); }); - describe('when the invite was sent successfully', () => { - const toastMessageSuccessful = 'Users were succesfully added'; + describe('when sending the invite for multiple members returned any error', () => { + const genericErrorMessage = 'Some of the members could not be added'; - it('displays the successful toastMessage', () => { + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: '123' }); + + wrapper.vm.$toast = { show: jest.fn() }; + jest + .spyOn(Api, 'inviteGroupMember') + .mockRejectedValue({ response: { data: { success: false } } }); + + findInviteButton().vm.$emit('click'); + }); + + it('displays the expected toastMessage', () => { expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( - toastMessageSuccessful, + genericErrorMessage, wrapper.vm.toastOptions, ); }); diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js new file mode 100644 index 00000000000..fb0bd6bb195 --- /dev/null +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -0,0 +1,112 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { GlTokenSelector } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; + +const label = 'testgroup'; +const placeholder = 'Search for a member'; +const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; +const user2 = { id: 2, name: 'Name Two', username: 'two_2', avatar_url: '' }; +const allUsers = [user1, user2]; + +const createComponent = () => { + return shallowMount(MembersTokenSelect, { + propsData: { + ariaLabelledby: label, + placeholder, + }, + }); +}; + +describe('MembersTokenSelect', () => { + let wrapper; + + beforeEach(() => { + jest.spyOn(Api, 'users').mockResolvedValue({ data: allUsers }); + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findTokenSelector = () => wrapper.find(GlTokenSelector); + + describe('rendering the token-selector component', () => { + it('renders with the correct props', () => { + const expectedProps = { + ariaLabelledby: label, + placeholder, + }; + + expect(findTokenSelector().props()).toEqual(expect.objectContaining(expectedProps)); + }); + }); + + describe('users', () => { + describe('when input is focused for the first time (modal auto-focus)', () => { + it('does not call the API', async () => { + findTokenSelector().vm.$emit('focus'); + + await waitForPromises(); + + expect(Api.users).not.toHaveBeenCalled(); + }); + }); + + describe('when input is manually focused', () => { + it('calls the API and sets dropdown items as request result', async () => { + const tokenSelector = findTokenSelector(); + + tokenSelector.vm.$emit('focus'); + tokenSelector.vm.$emit('blur'); + tokenSelector.vm.$emit('focus'); + + await waitForPromises(); + + expect(tokenSelector.props('dropdownItems')).toMatchObject(allUsers); + expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); + }); + }); + + describe('when text input is typed in', () => { + it('calls the API with search parameter', async () => { + const searchParam = 'One'; + const tokenSelector = findTokenSelector(); + + tokenSelector.vm.$emit('text-input', searchParam); + + await waitForPromises(); + + expect(Api.users).toHaveBeenCalledWith(searchParam, wrapper.vm.$options.queryOptions); + expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); + }); + }); + + describe('when user is selected', () => { + it('emits `input` event with selected users', () => { + findTokenSelector().vm.$emit('input', [ + { id: 1, name: 'John Smith' }, + { id: 2, name: 'Jane Doe' }, + ]); + + expect(wrapper.emitted().input[0][0]).toBe('1,2'); + }); + }); + }); + + describe('when text input is blurred', () => { + it('clears text input', async () => { + const tokenSelector = findTokenSelector(); + + tokenSelector.vm.$emit('blur'); + + await nextTick(); + + expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/issuable/related_issues/components/issue_token_spec.js b/spec/frontend/issuable/related_issues/components/issue_token_spec.js index f2cb9042ba6..1b4c6b548e2 100644 --- a/spec/frontend/issuable/related_issues/components/issue_token_spec.js +++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js @@ -137,9 +137,7 @@ describe('IssueToken', () => { }); it('tooltip should not be escaped', () => { - expect(findRemoveBtn().attributes('data-original-title')).toBe( - `Remove ${displayReference}`, - ); + expect(findRemoveBtn().attributes('aria-label')).toBe(`Remove ${displayReference}`); }); }); }); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js index b758b85beef..dd05f49b458 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js @@ -56,7 +56,7 @@ describe('RelatedIssuesBlock', () => { pathIdSeparator: PathIdSeparator.Issue, issuableType: 'issue', }, - slots: { headerText }, + slots: { 'header-text': headerText }, }); expect(wrapper.find('.card-title').html()).toContain(headerText); @@ -72,7 +72,7 @@ describe('RelatedIssuesBlock', () => { pathIdSeparator: PathIdSeparator.Issue, issuableType: 'issue', }, - slots: { headerActions }, + slots: { 'header-actions': headerActions }, }); expect(wrapper.find('[data-testid="custom-button"]').html()).toBe(headerActions); diff --git a/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js b/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js new file mode 100644 index 00000000000..52a238eac7c --- /dev/null +++ b/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js @@ -0,0 +1,97 @@ +import { shallowMount } from '@vue/test-utils'; + +import IssuableBulkEditSidebar from '~/issuable_list/components/issuable_bulk_edit_sidebar.vue'; + +const createComponent = ({ expanded = true } = {}) => + shallowMount(IssuableBulkEditSidebar, { + propsData: { + expanded, + }, + slots: { + 'bulk-edit-actions': ` + <button class="js-edit-issuables">Edit issuables</button> + `, + 'sidebar-items': ` + <button class="js-sidebar-dropdown">Labels</button> + `, + }, + }); + +describe('IssuableBulkEditSidebar', () => { + let wrapper; + + beforeEach(() => { + setFixtures('<div class="layout-page right-sidebar-collapsed"></div>'); + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('watch', () => { + describe('expanded', () => { + it.each` + expanded | layoutPageClass + ${true} | ${'right-sidebar-expanded'} + ${false} | ${'right-sidebar-collapsed'} + `( + 'sets class "$layoutPageClass" on element `.layout-page` when expanded prop is $expanded', + async ({ expanded, layoutPageClass }) => { + const wrappeCustom = createComponent({ + expanded: !expanded, + }); + + // We need to manually flip the value of `expanded` for + // watcher to trigger. + wrappeCustom.setProps({ + expanded, + }); + + await wrappeCustom.vm.$nextTick(); + + expect(document.querySelector('.layout-page').classList.contains(layoutPageClass)).toBe( + true, + ); + + wrappeCustom.destroy(); + }, + ); + }); + }); + + describe('template', () => { + it.each` + expanded | layoutPageClass + ${true} | ${'right-sidebar-expanded'} + ${false} | ${'right-sidebar-collapsed'} + `( + 'renders component container with class "$layoutPageClass" when expanded prop is $expanded', + async ({ expanded, layoutPageClass }) => { + const wrappeCustom = createComponent({ + expanded: !expanded, + }); + + // We need to manually flip the value of `expanded` for + // watcher to trigger. + wrappeCustom.setProps({ + expanded, + }); + + await wrappeCustom.vm.$nextTick(); + + expect(wrappeCustom.classes()).toContain(layoutPageClass); + + wrappeCustom.destroy(); + }, + ); + + it('renders contents for slot `bulk-edit-actions`', () => { + expect(wrapper.find('button.js-edit-issuables').exists()).toBe(true); + }); + + it('renders contents for slot `sidebar-items`', () => { + expect(wrapper.find('button.js-sidebar-dropdown').exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index a96a4e15e6c..3a9a0d3fd59 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -1,29 +1,37 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLink, GlLabel } from '@gitlab/ui'; +import { GlLink, GlLabel, GlIcon, GlFormCheckbox } from '@gitlab/ui'; import IssuableItem from '~/issuable_list/components/issuable_item.vue'; +import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data'; -const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable } = {}) => +const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots = {} } = {}) => shallowMount(IssuableItem, { propsData: { issuableSymbol, issuable, + enableLabelPermalinks: true, + showDiscussions: true, + showCheckbox: false, }, + slots, }); describe('IssuableItem', () => { const mockLabels = mockIssuable.labels.nodes; const mockAuthor = mockIssuable.author; + const originalUrl = gon.gitlab_url; let wrapper; beforeEach(() => { + gon.gitlab_url = 'http://0.0.0.0:3000'; wrapper = createComponent(); }); afterEach(() => { wrapper.destroy(); + gon.gitlab_url = originalUrl; }); describe('computed', () => { @@ -38,8 +46,8 @@ describe('IssuableItem', () => { authorId | returnValue ${1} | ${1} ${'1'} | ${1} - ${'gid://gitlab/User/1'} | ${'1'} - ${'foo'} | ${''} + ${'gid://gitlab/User/1'} | ${1} + ${'foo'} | ${null} `( 'returns $returnValue when value of `issuable.author.id` is $authorId', async ({ authorId, returnValue }) => { @@ -60,6 +68,30 @@ describe('IssuableItem', () => { ); }); + describe('isIssuableUrlExternal', () => { + it.each` + issuableWebUrl | urlType | returnValue + ${'/gitlab-org/gitlab-test/-/issues/2'} | ${'relative'} | ${false} + ${'http://0.0.0.0:3000/gitlab-org/gitlab-test/-/issues/1'} | ${'absolute and internal'} | ${false} + ${'http://jira.atlassian.net/browse/IG-1'} | ${'external'} | ${true} + ${'https://github.com/gitlabhq/gitlabhq/issues/1'} | ${'external'} | ${true} + `( + 'returns $returnValue when `issuable.webUrl` is $urlType', + async ({ issuableWebUrl, returnValue }) => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + webUrl: issuableWebUrl, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isIssuableUrlExternal).toBe(returnValue); + }, + ); + }); + describe('labels', () => { it('returns `issuable.labels.nodes` reference when it is available', () => { expect(wrapper.vm.labels).toEqual(mockLabels); @@ -92,6 +124,12 @@ describe('IssuableItem', () => { }); }); + describe('assignees', () => { + it('returns `issuable.assignees` reference when it is available', () => { + expect(wrapper.vm.assignees).toBe(mockIssuable.assignees); + }); + }); + describe('createdAt', () => { it('returns string containing timeago string based on `issuable.createdAt`', () => { expect(wrapper.vm.createdAt).toContain('created'); @@ -105,6 +143,31 @@ describe('IssuableItem', () => { expect(wrapper.vm.updatedAt).toContain('ago'); }); }); + + describe('showDiscussions', () => { + it.each` + userDiscussionsCount | returnValue + ${0} | ${true} + ${1} | ${true} + ${undefined} | ${false} + ${null} | ${false} + `( + 'returns $returnValue when issuable.userDiscussionsCount is $userDiscussionsCount', + ({ userDiscussionsCount, returnValue }) => { + const wrapperWithDiscussions = createComponent({ + issuableSymbol: '#', + issuable: { + ...mockIssuable, + userDiscussionsCount, + }, + }); + + expect(wrapperWithDiscussions.vm.showDiscussions).toBe(returnValue); + + wrapperWithDiscussions.destroy(); + }, + ); + }); }); describe('methods', () => { @@ -120,6 +183,34 @@ describe('IssuableItem', () => { }, ); }); + + describe('labelTitle', () => { + it.each` + label | propWithTitle | returnValue + ${{ title: 'foo' }} | ${'title'} | ${'foo'} + ${{ name: 'foo' }} | ${'name'} | ${'foo'} + `('returns string value of `label.$propWithTitle`', ({ label, returnValue }) => { + expect(wrapper.vm.labelTitle(label)).toBe(returnValue); + }); + }); + + describe('labelTarget', () => { + it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => { + expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe( + '?label_name%5B%5D=Documentation%20Update', + ); + }); + + it('returns string "#" for a provided label param when `enableLabelPermalinks` is false', async () => { + wrapper.setProps({ + enableLabelPermalinks: false, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe('#'); + }); + }); }); describe('template', () => { @@ -128,9 +219,47 @@ describe('IssuableItem', () => { expect(titleEl.exists()).toBe(true); expect(titleEl.find(GlLink).attributes('href')).toBe(mockIssuable.webUrl); + expect(titleEl.find(GlLink).attributes('target')).not.toBeDefined(); expect(titleEl.find(GlLink).text()).toBe(mockIssuable.title); }); + it('renders checkbox when `showCheckbox` prop is true', async () => { + wrapper.setProps({ + showCheckbox: true, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlFormCheckbox).exists()).toBe(true); + expect(wrapper.find(GlFormCheckbox).attributes('checked')).not.toBeDefined(); + + wrapper.setProps({ + checked: true, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlFormCheckbox).attributes('checked')).toBe('true'); + }); + + it('renders issuable title with `target` set as "_blank" when issuable.webUrl is external', async () => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + webUrl: 'http://jira.atlassian.net/browse/IG-1', + }, + }); + + await wrapper.vm.$nextTick(); + + expect( + wrapper + .find('[data-testid="issuable-title"]') + .find(GlLink) + .attributes('target'), + ).toBe('_blank'); + }); + it('renders issuable reference', () => { const referenceEl = wrapper.find('[data-testid="issuable-reference"]'); @@ -138,6 +267,24 @@ describe('IssuableItem', () => { expect(referenceEl.text()).toBe(`#${mockIssuable.iid}`); }); + it('renders issuable reference via slot', () => { + const wrapperWithRefSlot = createComponent({ + issuableSymbol: '#', + issuable: mockIssuable, + slots: { + reference: ` + <b class="js-reference">${mockIssuable.iid}</b> + `, + }, + }); + const referenceEl = wrapperWithRefSlot.find('.js-reference'); + + expect(referenceEl.exists()).toBe(true); + expect(referenceEl.text()).toBe(`${mockIssuable.iid}`); + + wrapperWithRefSlot.destroy(); + }); + it('renders issuable createdAt info', () => { const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]'); @@ -151,7 +298,7 @@ describe('IssuableItem', () => { expect(authorEl.exists()).toBe(true); expect(authorEl.attributes()).toMatchObject({ - 'data-user-id': wrapper.vm.authorId, + 'data-user-id': `${wrapper.vm.authorId}`, 'data-username': mockAuthor.username, 'data-name': mockAuthor.name, 'data-avatar-url': mockAuthor.avatarUrl, @@ -160,6 +307,42 @@ describe('IssuableItem', () => { expect(authorEl.text()).toBe(mockAuthor.name); }); + it('renders issuable author info via slot', () => { + const wrapperWithAuthorSlot = createComponent({ + issuableSymbol: '#', + issuable: mockIssuable, + slots: { + reference: ` + <span class="js-author">${mockAuthor.name}</span> + `, + }, + }); + const authorEl = wrapperWithAuthorSlot.find('.js-author'); + + expect(authorEl.exists()).toBe(true); + expect(authorEl.text()).toBe(mockAuthor.name); + + wrapperWithAuthorSlot.destroy(); + }); + + it('renders timeframe via slot', () => { + const wrapperWithTimeframeSlot = createComponent({ + issuableSymbol: '#', + issuable: mockIssuable, + slots: { + timeframe: ` + <b class="js-timeframe">Jan 1, 2020 - Mar 31, 2020</b> + `, + }, + }); + const timeframeEl = wrapperWithTimeframeSlot.find('.js-timeframe'); + + expect(timeframeEl.exists()).toBe(true); + expect(timeframeEl.text()).toBe('Jan 1, 2020 - Mar 31, 2020'); + + wrapperWithTimeframeSlot.destroy(); + }); + it('renders gl-label component for each label present within `issuable` prop', () => { const labelsEl = wrapper.findAll(GlLabel); @@ -170,10 +353,52 @@ describe('IssuableItem', () => { title: mockLabels[0].title, description: mockLabels[0].description, scoped: false, + target: wrapper.vm.labelTarget(mockLabels[0]), size: 'sm', }); }); + it('renders issuable status via slot', () => { + const wrapperWithStatusSlot = createComponent({ + issuableSymbol: '#', + issuable: mockIssuable, + slots: { + status: ` + <b class="js-status">${mockIssuable.state}</b> + `, + }, + }); + const statusEl = wrapperWithStatusSlot.find('.js-status'); + + expect(statusEl.exists()).toBe(true); + expect(statusEl.text()).toBe(`${mockIssuable.state}`); + + wrapperWithStatusSlot.destroy(); + }); + + it('renders discussions count', () => { + const discussionsEl = wrapper.find('[data-testid="issuable-discussions"]'); + + expect(discussionsEl.exists()).toBe(true); + expect(discussionsEl.find(GlLink).attributes()).toMatchObject({ + title: 'Comments', + href: `${mockIssuable.webUrl}#notes`, + }); + expect(discussionsEl.find(GlIcon).props('name')).toBe('comments'); + expect(discussionsEl.find(GlLink).text()).toContain('2'); + }); + + it('renders issuable-assignees component', () => { + const assigneesEl = wrapper.find(IssuableAssignees); + + expect(assigneesEl.exists()).toBe(true); + expect(assigneesEl.props()).toMatchObject({ + assignees: mockIssuable.assignees, + iconSize: 16, + maxVisible: 4, + }); + }); + it('renders issuable updatedAt info', () => { const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js index 34184522b55..add5d9e8e2d 100644 --- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js +++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js @@ -1,16 +1,21 @@ import { mount } from '@vue/test-utils'; -import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { GlSkeletonLoading, GlPagination } from '@gitlab/ui'; + +import { TEST_HOST } from 'helpers/test_constants'; import IssuableListRoot from '~/issuable_list/components/issuable_list_root.vue'; import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue'; import IssuableItem from '~/issuable_list/components/issuable_item.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { mockIssuableListProps } from '../mock_data'; +import { mockIssuableListProps, mockIssuables } from '../mock_data'; -const createComponent = (propsData = mockIssuableListProps) => +const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) => mount(IssuableListRoot, { - propsData, + propsData: props, + data() { + return data; + }, slots: { 'nav-actions': ` <button class="js-new-issuable">New issuable</button> @@ -32,6 +37,139 @@ describe('IssuableListRoot', () => { wrapper.destroy(); }); + describe('computed', () => { + const mockCheckedIssuables = { + [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, + [mockIssuables[1].iid]: { checked: true, issuable: mockIssuables[1] }, + [mockIssuables[2].iid]: { checked: true, issuable: mockIssuables[2] }, + }; + + const mIssuables = [mockIssuables[0], mockIssuables[1], mockIssuables[2]]; + + describe('skeletonItemCount', () => { + it.each` + totalItems | defaultPageSize | currentPage | returnValue + ${100} | ${20} | ${1} | ${20} + ${105} | ${20} | ${6} | ${5} + ${7} | ${20} | ${1} | ${7} + ${0} | ${20} | ${1} | ${5} + `( + 'returns $returnValue when totalItems is $totalItems, defaultPageSize is $defaultPageSize and currentPage is $currentPage', + async ({ totalItems, defaultPageSize, currentPage, returnValue }) => { + wrapper.setProps({ + totalItems, + defaultPageSize, + currentPage, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.skeletonItemCount).toBe(returnValue); + }, + ); + }); + + describe('allIssuablesChecked', () => { + it.each` + checkedIssuables | issuables | specTitle | returnValue + ${mockCheckedIssuables} | ${mIssuables} | ${'same as'} | ${true} + ${{}} | ${mIssuables} | ${'not same as'} | ${false} + `( + 'returns $returnValue when bulkEditIssuables count is $specTitle issuables count', + async ({ checkedIssuables, issuables, returnValue }) => { + wrapper.setProps({ + issuables, + }); + + await wrapper.vm.$nextTick(); + + wrapper.setData({ + checkedIssuables, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.allIssuablesChecked).toBe(returnValue); + }, + ); + }); + + describe('bulkEditIssuables', () => { + it('returns array of issuables which have `checked` set to true within checkedIssuables map', async () => { + wrapper.setData({ + checkedIssuables: mockCheckedIssuables, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.bulkEditIssuables).toHaveLength(mIssuables.length); + }); + }); + }); + + describe('watch', () => { + describe('issuables', () => { + it('populates `checkedIssuables` prop with all issuables', async () => { + wrapper.setProps({ + issuables: [mockIssuables[0]], + }); + + await wrapper.vm.$nextTick(); + + expect(Object.keys(wrapper.vm.checkedIssuables)).toHaveLength(1); + expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ + checked: false, + issuable: mockIssuables[0], + }); + }); + }); + + describe('urlParams', () => { + it('updates window URL reflecting props within `urlParams`', async () => { + const urlParams = { + state: 'closed', + sort: 'updated_asc', + page: 1, + search: 'foo', + }; + + wrapper.setProps({ + urlParams, + }); + + await wrapper.vm.$nextTick(); + + expect(global.window.location.href).toBe( + `${TEST_HOST}/?state=${urlParams.state}&sort=${urlParams.sort}&page=${urlParams.page}&search=${urlParams.search}`, + ); + }); + }); + }); + + describe('methods', () => { + describe('issuableId', () => { + it('returns id value from provided issuable object', () => { + expect(wrapper.vm.issuableId({ id: 1 })).toBe(1); + expect(wrapper.vm.issuableId({ iid: 1 })).toBe(1); + expect(wrapper.vm.issuableId({})).toBeDefined(); + }); + }); + + describe('issuableChecked', () => { + it('returns boolean value representing checked status of issuable item', async () => { + wrapper.setData({ + checkedIssuables: { + [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.issuableChecked(mockIssuables[0])).toBe(true); + }); + }); + }); + describe('template', () => { it('renders component container element with class "issuable-list-container"', () => { expect(wrapper.classes()).toContain('issuable-list-container'); @@ -86,7 +224,7 @@ describe('IssuableListRoot', () => { await wrapper.vm.$nextTick(); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(wrapper.vm.skeletonItemCount); }); it('renders issuable-item component for each item within `issuables` array', () => { @@ -114,6 +252,7 @@ describe('IssuableListRoot', () => { it('renders gl-pagination when `showPaginationControls` prop is true', async () => { wrapper.setProps({ showPaginationControls: true, + totalItems: 10, }); await wrapper.vm.$nextTick(); @@ -125,18 +264,51 @@ describe('IssuableListRoot', () => { value: 1, prevPage: 0, nextPage: 2, + totalItems: 10, align: 'center', }); }); }); describe('events', () => { + let wrapperChecked; + + beforeEach(() => { + wrapperChecked = createComponent({ + data: { + checkedIssuables: { + [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, + }, + }, + }); + }); + + afterEach(() => { + wrapperChecked.destroy(); + }); + it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => { wrapper.find(IssuableTabs).vm.$emit('click'); expect(wrapper.emitted('click-tab')).toBeTruthy(); }); + it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => { + const searchEl = wrapperChecked.find(FilteredSearchBar); + + searchEl.vm.$emit('checked-input', true); + + await wrapperChecked.vm.$nextTick(); + + expect(searchEl.emitted('checked-input')).toBeTruthy(); + expect(searchEl.emitted('checked-input').length).toBe(1); + + expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ + checked: true, + issuable: mockIssuables[0], + }); + }); + it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => { const searchEl = wrapper.find(FilteredSearchBar); @@ -146,6 +318,22 @@ describe('IssuableListRoot', () => { expect(wrapper.emitted('sort')).toBeTruthy(); }); + it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => { + const issuableItem = wrapperChecked.findAll(IssuableItem).at(0); + + issuableItem.vm.$emit('checked-input', true); + + await wrapperChecked.vm.$nextTick(); + + expect(issuableItem.emitted('checked-input')).toBeTruthy(); + expect(issuableItem.emitted('checked-input').length).toBe(1); + + expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({ + checked: true, + issuable: mockIssuables[0], + }); + }); + it('gl-pagination component emits `page-change` event on `input` event', async () => { wrapper.setProps({ showPaginationControls: true, diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js index 8eab2ca6f94..e19a337473a 100644 --- a/spec/frontend/issuable_list/mock_data.js +++ b/spec/frontend/issuable_list/mock_data.js @@ -51,6 +51,8 @@ export const mockIssuable = { labels: { nodes: mockLabels, }, + assignees: [mockAuthor], + userDiscussionsCount: 2, }; export const mockIssuables = [ diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index f4095d4de96..dde4e8458d5 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -17,6 +17,7 @@ import { import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; import DescriptionComponent from '~/issue_show/components/description.vue'; import PinnedLinks from '~/issue_show/components/pinned_links.vue'; +import { IssuableStatus, IssuableStatusText } from '~/issue_show/constants'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); @@ -36,6 +37,10 @@ describe('Issuable output', () => { const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); + const findLockedBadge = () => wrapper.find('[data-testid="locked"]'); + + const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]'); + const mountComponent = (props = {}, options = {}) => { wrapper = mount(IssuableApp, { propsData: { ...appProps, ...props }, @@ -532,7 +537,7 @@ describe('Issuable output', () => { describe('sticky header', () => { describe('when title is in view', () => { it('is not shown', () => { - expect(wrapper.find('.issue-sticky-header').exists()).toBe(false); + expect(findStickyHeader().exists()).toBe(false); }); }); @@ -542,24 +547,45 @@ describe('Issuable output', () => { wrapper.find(GlIntersectionObserver).vm.$emit('disappear'); }); - it('is shown with title', () => { + it('shows with title', () => { expect(findStickyHeader().text()).toContain('Sticky header title'); }); - it('is shown with Open when status is opened', () => { - wrapper.setProps({ issuableStatus: 'opened' }); + it.each` + title | state + ${'shows with Open when status is opened'} | ${IssuableStatus.Open} + ${'shows with Closed when status is closed'} | ${IssuableStatus.Closed} + ${'shows with Open when status is reopened'} | ${IssuableStatus.Reopened} + `('$title', async ({ state }) => { + wrapper.setProps({ issuableStatus: state }); - return wrapper.vm.$nextTick(() => { - expect(findStickyHeader().text()).toContain('Open'); - }); + await wrapper.vm.$nextTick(); + + expect(findStickyHeader().text()).toContain(IssuableStatusText[state]); }); - it('is shown with Closed when status is closed', () => { - wrapper.setProps({ issuableStatus: 'closed' }); + it.each` + title | isConfidential + ${'does not show confidential badge when issue is not confidential'} | ${true} + ${'shows confidential badge when issue is confidential'} | ${false} + `('$title', async ({ isConfidential }) => { + wrapper.setProps({ isConfidential }); - return wrapper.vm.$nextTick(() => { - expect(findStickyHeader().text()).toContain('Closed'); - }); + await wrapper.vm.$nextTick(); + + expect(findConfidentialBadge().exists()).toBe(isConfidential); + }); + + it.each` + title | isLocked + ${'does not show locked badge when issue is not locked'} | ${true} + ${'shows locked badge when issue is locked'} | ${false} + `('$title', async ({ isLocked }) => { + wrapper.setProps({ isLocked }); + + await wrapper.vm.$nextTick(); + + expect(findLockedBadge().exists()).toBe(isLocked); }); }); }); diff --git a/spec/frontend/issue_show/components/header_actions_spec.js b/spec/frontend/issue_show/components/header_actions_spec.js new file mode 100644 index 00000000000..67b8665a889 --- /dev/null +++ b/spec/frontend/issue_show/components/header_actions_spec.js @@ -0,0 +1,328 @@ +import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { IssuableType } from '~/issuable_show/constants'; +import HeaderActions from '~/issue_show/components/header_actions.vue'; +import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; +import promoteToEpicMutation from '~/issue_show/queries/promote_to_epic.mutation.graphql'; +import * as urlUtility from '~/lib/utils/url_utility'; +import createStore from '~/notes/stores'; + +jest.mock('~/flash'); + +describe('HeaderActions component', () => { + let dispatchEventSpy; + let mutateMock; + let wrapper; + let visitUrlSpy; + + const localVue = createLocalVue(); + localVue.use(Vuex); + const store = createStore(); + + const defaultProps = { + canCreateIssue: true, + canPromoteToEpic: true, + canReopenIssue: true, + canReportSpam: true, + canUpdateIssue: true, + iid: '32', + isIssueAuthor: true, + issueType: IssuableType.Issue, + newIssuePath: 'gitlab-org/gitlab-test/-/issues/new', + projectPath: 'gitlab-org/gitlab-test', + reportAbusePath: + '-/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%2Fgitlab-org%2Fgitlab-test%2F-%2Fissues%2F32&user_id=1', + submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam', + }; + + const updateIssueMutationResponse = { data: { updateIssue: { errors: [] } } }; + + const promoteToEpicMutationResponse = { + data: { + promoteToEpic: { + errors: [], + epic: { + webPath: '/groups/gitlab-org/-/epics/1', + }, + }, + }, + }; + + const promoteToEpicMutationErrorResponse = { + data: { + promoteToEpic: { + errors: ['The issue has already been promoted to an epic.'], + epic: {}, + }, + }, + }; + + const findToggleIssueStateButton = () => wrapper.find(GlButton); + + const findDropdownAt = index => wrapper.findAll(GlDropdown).at(index); + + const findMobileDropdownItems = () => findDropdownAt(0).findAll(GlDropdownItem); + + const findDesktopDropdownItems = () => findDropdownAt(1).findAll(GlDropdownItem); + + const findModal = () => wrapper.find(GlModal); + + const findModalLinkAt = index => + findModal() + .findAll(GlLink) + .at(index); + + const mountComponent = ({ + props = {}, + issueState = IssuableStatus.Open, + blockedByIssues = [], + mutateResponse = {}, + } = {}) => { + mutateMock = jest.fn().mockResolvedValue(mutateResponse); + + store.getters.getNoteableData.state = issueState; + store.getters.getNoteableData.blocked_by_issues = blockedByIssues; + + return shallowMount(HeaderActions, { + localVue, + store, + provide: { + ...defaultProps, + ...props, + }, + mocks: { + $apollo: { + mutate: mutateMock, + }, + }, + }); + }; + + afterEach(() => { + if (dispatchEventSpy) { + dispatchEventSpy.mockRestore(); + } + if (visitUrlSpy) { + visitUrlSpy.mockRestore(); + } + wrapper.destroy(); + }); + + describe.each` + issueType + ${IssuableType.Issue} + ${IssuableType.Incident} + `('when issue type is $issueType', ({ issueType }) => { + describe('close/reopen button', () => { + describe.each` + description | issueState | buttonText | newIssueState + ${`when the ${issueType} is open`} | ${IssuableStatus.Open} | ${`Close ${issueType}`} | ${IssueStateEvent.Close} + ${`when the ${issueType} is closed`} | ${IssuableStatus.Closed} | ${`Reopen ${issueType}`} | ${IssueStateEvent.Reopen} + `('$description', ({ issueState, buttonText, newIssueState }) => { + beforeEach(() => { + dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + + wrapper = mountComponent({ + props: { issueType }, + issueState, + mutateResponse: updateIssueMutationResponse, + }); + }); + + it(`has text "${buttonText}"`, () => { + expect(findToggleIssueStateButton().text()).toBe(buttonText); + }); + + it('calls apollo mutation', () => { + findToggleIssueStateButton().vm.$emit('click'); + + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + stateEvent: newIssueState, + }, + }, + }), + ); + }); + + it('dispatches a custom event to update the issue page', async () => { + findToggleIssueStateButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe.each` + description | isCloseIssueItemVisible | findDropdownItems + ${'mobile dropdown'} | ${true} | ${findMobileDropdownItems} + ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems} + `('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => { + describe.each` + description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic + ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} + ${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} + ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} + ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} + ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} + `( + '$description', + ({ + itemText, + isItemVisible, + canUpdateIssue, + canCreateIssue, + isIssueAuthor, + canReportSpam, + canPromoteToEpic, + }) => { + beforeEach(() => { + wrapper = mountComponent({ + props: { + canUpdateIssue, + canCreateIssue, + isIssueAuthor, + issueType, + canReportSpam, + canPromoteToEpic, + }, + }); + }); + + it(`${isItemVisible ? 'shows' : 'hides'} "${itemText}" item`, () => { + expect( + findDropdownItems() + .filter(item => item.text() === itemText) + .exists(), + ).toBe(isItemVisible); + }); + }, + ); + }); + }); + + describe('when "Promote to epic" button is clicked', () => { + describe('when response is successful', () => { + beforeEach(() => { + visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + + wrapper = mountComponent({ + mutateResponse: promoteToEpicMutationResponse, + }); + + wrapper.find('[data-testid="promote-button"]').vm.$emit('click'); + }); + + it('invokes GraphQL mutation when clicked', () => { + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + mutation: promoteToEpicMutation, + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + }, + }, + }), + ); + }); + + it('shows a success message and tells the user they are being redirected', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'The issue was successfully promoted to an epic. Redirecting to epic...', + type: FLASH_TYPES.SUCCESS, + }); + }); + + it('redirects to newly created epic path', () => { + expect(visitUrlSpy).toHaveBeenCalledWith( + promoteToEpicMutationResponse.data.promoteToEpic.epic.webPath, + ); + }); + }); + + describe('when response contains errors', () => { + beforeEach(() => { + visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + + wrapper = mountComponent({ + mutateResponse: promoteToEpicMutationErrorResponse, + }); + + wrapper.find('[data-testid="promote-button"]').vm.$emit('click'); + }); + + it('shows an error message', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: promoteToEpicMutationErrorResponse.data.promoteToEpic.errors.join('; '), + }); + }); + }); + }); + + describe('modal', () => { + const blockedByIssues = [ + { iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' }, + { iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' }, + ]; + + beforeEach(() => { + wrapper = mountComponent({ blockedByIssues }); + }); + + it('has title text', () => { + expect(findModal().attributes('title')).toBe( + 'Are you sure you want to close this blocked issue?', + ); + }); + + it('has body text', () => { + expect(findModal().text()).toContain( + 'This issue is currently blocked by the following issues:', + ); + }); + + it('calls apollo mutation when primary button is clicked', () => { + findModal().vm.$emit('primary'); + + expect(mutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + iid: defaultProps.iid.toString(), + projectPath: defaultProps.projectPath, + stateEvent: IssueStateEvent.Close, + }, + }, + }), + ); + }); + + describe.each` + ordinal | index + ${'first'} | ${0} + ${'second'} | ${1} + `('$ordinal blocked-by issue link', ({ index }) => { + it('has link text', () => { + expect(findModalLinkAt(index).text()).toBe(`#${blockedByIssues[index].iid}`); + }); + + it('has url', () => { + expect(findModalLinkAt(index).attributes('href')).toBe(blockedByIssues[index].web_url); + }); + }); + }); +}); diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js index c0175e774a2..7a48353af94 100644 --- a/spec/frontend/issue_show/issue_spec.js +++ b/spec/frontend/issue_show/issue_spec.js @@ -2,9 +2,10 @@ import MockAdapter from 'axios-mock-adapter'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; -import initIssuableApp from '~/issue_show/issue'; +import { initIssuableApp } from '~/issue_show/issue'; import * as parseData from '~/issue_show/utils/parse_data'; import { appProps } from './mock_data'; +import createStore from '~/notes/stores'; const mock = new MockAdapter(axios); mock.onGet().reply(200); @@ -30,7 +31,7 @@ describe('Issue show index', () => { }); const issuableData = parseData.parseIssuableData(); - initIssuableApp(issuableData); + initIssuableApp(issuableData, createStore()); await waitForPromises(); diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js index c20684cc385..6e584152551 100644 --- a/spec/frontend/issues_list/components/issuable_spec.js +++ b/spec/frontend/issues_list/components/issuable_spec.js @@ -38,7 +38,7 @@ describe('Issuable component', () => { let DateOrig; let wrapper; - const factory = (props = {}, scopedLabels = false) => { + const factory = (props = {}, scopedLabelsAvailable = false) => { wrapper = shallowMount(Issuable, { propsData: { issuable: simpleIssue, @@ -46,9 +46,7 @@ describe('Issuable component', () => { ...props, }, provide: { - glFeatures: { - scopedLabels, - }, + scopedLabelsAvailable, }, stubs: { 'gl-sprintf': GlSprintf, diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index cd0266068aa..fe6d9a34078 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -91,6 +91,8 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = > <!----> + <!----> + <span class="gl-new-dropdown-button-text" > @@ -98,6 +100,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = </span> <svg + aria-hidden="true" class="gl-button-icon dropdown-chevron gl-icon s16" data-testid="chevron-down-icon" > @@ -202,6 +205,8 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = > <!----> + <!----> + <span class="gl-new-dropdown-button-text" > @@ -209,6 +214,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = </span> <svg + aria-hidden="true" class="gl-button-icon dropdown-chevron gl-icon s16" data-testid="chevron-down-icon" > diff --git a/spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js b/spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js new file mode 100644 index 00000000000..08973223c08 --- /dev/null +++ b/spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js @@ -0,0 +1,76 @@ +import { GlLink, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import JobRetryForwardDeploymentModal from '~/jobs/components/job_retry_forward_deployment_modal.vue'; +import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants'; +import createStore from '~/jobs/store'; +import job from '../mock_data'; + +describe('Job Retry Forward Deployment Modal', () => { + let store; + let wrapper; + + const retryOutdatedJobDocsUrl = 'url-to-docs'; + const findLink = () => wrapper.find(GlLink); + const findModal = () => wrapper.find(GlModal); + + const createWrapper = ({ props = {}, provide = {}, stubs = {} } = {}) => { + store = createStore(); + wrapper = shallowMount(JobRetryForwardDeploymentModal, { + propsData: { + modalId: 'modal-id', + href: job.retry_path, + ...props, + }, + provide, + store, + stubs, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + beforeEach(createWrapper); + + describe('Modal configuration', () => { + it('should display the correct messages', () => { + const modal = findModal(); + expect(modal.attributes('title')).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.title); + expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.info); + expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.areYouSure); + }); + }); + + describe('Modal docs help link', () => { + it('should not display an info link when none is provided', () => { + createWrapper(); + + expect(findLink().exists()).toBe(false); + }); + + it('should display an info link when one is provided', () => { + createWrapper({ provide: { retryOutdatedJobDocsUrl } }); + + expect(findLink().attributes('href')).toBe(retryOutdatedJobDocsUrl); + expect(findLink().text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.moreInfo); + }); + }); + + describe('Modal actions', () => { + beforeEach(createWrapper); + + it('should correctly configure the primary action', () => { + expect(findModal().props('actionPrimary').attributes).toMatchObject([ + { + 'data-method': 'post', + href: job.retry_path, + variant: 'danger', + }, + ]); + }); + }); +}); diff --git a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js new file mode 100644 index 00000000000..be684769b46 --- /dev/null +++ b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js @@ -0,0 +1,132 @@ +import { shallowMount } from '@vue/test-utils'; +import SidebarJobDetailsContainer from '~/jobs/components/sidebar_job_details_container.vue'; +import DetailRow from '~/jobs/components/sidebar_detail_row.vue'; +import createStore from '~/jobs/store'; +import { extendedWrapper } from '../../helpers/vue_test_utils_helper'; +import job from '../mock_data'; + +describe('Job Sidebar Details Container', () => { + let store; + let wrapper; + + const findJobTimeout = () => wrapper.findByTestId('job-timeout'); + const findJobTags = () => wrapper.findByTestId('job-tags'); + const findAllDetailsRow = () => wrapper.findAll(DetailRow); + + const createWrapper = ({ props = {} } = {}) => { + store = createStore(); + wrapper = extendedWrapper( + shallowMount(SidebarJobDetailsContainer, { + propsData: props, + store, + stubs: { + DetailRow, + }, + }), + ); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('when no details are available', () => { + it('should render an empty container', () => { + createWrapper(); + + expect(wrapper.isEmpty()).toBe(true); + }); + }); + + describe('when some of the details are available', () => { + beforeEach(createWrapper); + + it.each([ + ['duration', 'Duration: 6 seconds'], + ['erased_at', 'Erased: 3 weeks ago'], + ['finished_at', 'Finished: 3 weeks ago'], + ['queued', 'Queued: 9 seconds'], + ['runner', 'Runner: local ci runner (#1)'], + ['coverage', 'Coverage: 20%'], + ])('uses %s to render job-%s', async (detail, value) => { + await store.dispatch('receiveJobSuccess', { [detail]: job[detail] }); + const detailsRow = findAllDetailsRow(); + + expect(wrapper.isEmpty()).toBe(false); + expect(detailsRow).toHaveLength(1); + expect(detailsRow.at(0).text()).toBe(value); + }); + + it('only renders tags', async () => { + const { tags } = job; + await store.dispatch('receiveJobSuccess', { tags }); + const tagsComponent = findJobTags(); + + expect(wrapper.isEmpty()).toBe(false); + expect(tagsComponent.text()).toBe('Tags: tag'); + }); + }); + + describe('when all the info are available', () => { + it('renders all the details components', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', job); + + expect(findAllDetailsRow()).toHaveLength(7); + }); + }); + + describe('timeout', () => { + const { + metadata: { timeout_human_readable, timeout_source }, + } = job; + + beforeEach(createWrapper); + + it('does not render if metadata is empty', async () => { + const metadata = {}; + await store.dispatch('receiveJobSuccess', { metadata }); + const detailsRow = findAllDetailsRow(); + + expect(wrapper.isEmpty()).toBe(true); + expect(detailsRow.exists()).toBe(false); + }); + + it('uses metadata to render timeout', async () => { + const metadata = { timeout_human_readable }; + await store.dispatch('receiveJobSuccess', { metadata }); + const detailsRow = findAllDetailsRow(); + + expect(wrapper.isEmpty()).toBe(false); + expect(detailsRow).toHaveLength(1); + expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s'); + }); + + it('uses metadata to render timeout and the source', async () => { + const metadata = { timeout_human_readable, timeout_source }; + await store.dispatch('receiveJobSuccess', { metadata }); + const detailsRow = findAllDetailsRow(); + + expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s (from runner)'); + }); + + it('should not render when no time is provided', async () => { + const metadata = { timeout_source }; + await store.dispatch('receiveJobSuccess', { metadata }); + + expect(findJobTimeout().exists()).toBe(false); + }); + + it('should pass the help URL', async () => { + const helpUrl = 'fakeUrl'; + const props = { runnerHelpUrl: helpUrl }; + createWrapper({ props }); + await store.dispatch('receiveJobSuccess', { metadata: { timeout_human_readable } }); + + expect(findJobTimeout().props('helpUrl')).toBe(helpUrl); + }); + }); +}); diff --git a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js new file mode 100644 index 00000000000..4bf697ab7cc --- /dev/null +++ b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js @@ -0,0 +1,70 @@ +import { GlButton, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import job from '../mock_data'; +import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue'; +import createStore from '~/jobs/store'; + +describe('Job Sidebar Retry Button', () => { + let store; + let wrapper; + + const forwardDeploymentFailure = 'forward_deployment_failure'; + const findRetryButton = () => wrapper.find(GlButton); + const findRetryLink = () => wrapper.find(GlLink); + + const createWrapper = ({ props = {} } = {}) => { + store = createStore(); + wrapper = shallowMount(JobsSidebarRetryButton, { + propsData: { + href: job.retry_path, + modalId: 'modal-id', + ...props, + }, + store, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + beforeEach(createWrapper); + + it.each([ + [null, false, true], + ['unmet_prerequisites', false, true], + [forwardDeploymentFailure, true, false], + ])( + 'when error is: %s, should render button: %s | should render link: %s', + async (failureReason, buttonExists, linkExists) => { + await store.dispatch('receiveJobSuccess', { ...job, failure_reason: failureReason }); + + expect(findRetryButton().exists()).toBe(buttonExists); + expect(findRetryLink().exists()).toBe(linkExists); + expect(wrapper.text()).toMatch('Retry'); + }, + ); + + describe('Button', () => { + it('should have the correct configuration', async () => { + await store.dispatch('receiveJobSuccess', { failure_reason: forwardDeploymentFailure }); + + expect(findRetryButton().attributes()).toMatchObject({ + category: 'primary', + variant: 'info', + }); + }); + }); + + describe('Link', () => { + it('should have the correct configuration', () => { + expect(findRetryLink().attributes()).toMatchObject({ + 'data-method': 'post', + href: job.retry_path, + }); + }); + }); +}); diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js index c2412a807c3..314b23ec29b 100644 --- a/spec/frontend/jobs/components/log/line_spec.js +++ b/spec/frontend/jobs/components/log/line_spec.js @@ -2,21 +2,26 @@ import { shallowMount } from '@vue/test-utils'; import Line from '~/jobs/components/log/line.vue'; import LineNumber from '~/jobs/components/log/line_number.vue'; +const httpUrl = 'http://example.com'; +const httpsUrl = 'https://example.com'; + +const mockProps = ({ text = 'Running with gitlab-runner 12.1.0 (de7731dd)' } = {}) => ({ + line: { + content: [ + { + text, + style: 'term-fg-l-green', + }, + ], + lineNumber: 0, + }, + path: '/jashkenas/underscore/-/jobs/335', +}); + describe('Job Log Line', () => { let wrapper; - - const data = { - line: { - content: [ - { - text: 'Running with gitlab-runner 12.1.0 (de7731dd)', - style: 'term-fg-l-green', - }, - ], - lineNumber: 0, - }, - path: '/jashkenas/underscore/-/jobs/335', - }; + let data; + let originalGon; const createComponent = (props = {}) => { wrapper = shallowMount(Line, { @@ -26,12 +31,25 @@ describe('Job Log Line', () => { }); }; + const findLine = () => wrapper.find('span'); + const findLink = () => findLine().find('a'); + const findLinksAt = i => + findLine() + .findAll('a') + .at(i); + beforeEach(() => { + originalGon = window.gon; + window.gon.features = { + ciJobLineLinks: false, + }; + + data = mockProps(); createComponent(data); }); afterEach(() => { - wrapper.destroy(); + window.gon = originalGon; }); it('renders the line number component', () => { @@ -39,10 +57,109 @@ describe('Job Log Line', () => { }); it('renders a span the provided text', () => { - expect(wrapper.find('span').text()).toBe(data.line.content[0].text); + expect(findLine().text()).toBe(data.line.content[0].text); }); it('renders the provided style as a class attribute', () => { - expect(wrapper.find('span').classes()).toContain(data.line.content[0].style); + expect(findLine().classes()).toContain(data.line.content[0].style); + }); + + describe.each([true, false])('when feature ci_job_line_links enabled = %p', ciJobLineLinks => { + beforeEach(() => { + window.gon.features = { + ciJobLineLinks, + }; + }); + + it('renders text with symbols', () => { + const text = 'apt-get update < /dev/null > /dev/null'; + createComponent(mockProps({ text })); + + expect(findLine().text()).toBe(text); + }); + + it.each` + tag | text + ${'a'} | ${'<a href="#">linked</a>'} + ${'script'} | ${'<script>doEvil();</script>'} + ${'strong'} | ${'<strong>highlighted</strong>'} + `('escapes `<$tag>` tags in text', ({ tag, text }) => { + createComponent(mockProps({ text })); + + expect( + findLine() + .find(tag) + .exists(), + ).toBe(false); + expect(findLine().text()).toBe(text); + }); + }); + + describe('when ci_job_line_links is enabled', () => { + beforeEach(() => { + window.gon.features = { + ciJobLineLinks: true, + }; + }); + + it('renders an http link', () => { + createComponent(mockProps({ text: httpUrl })); + + expect(findLink().text()).toBe(httpUrl); + expect(findLink().attributes().href).toBe(httpUrl); + }); + + it('renders an https link', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().text()).toBe(httpsUrl); + expect(findLink().attributes().href).toBe(httpsUrl); + }); + + it('renders a multiple links surrounded by text', () => { + createComponent(mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl}` })); + expect(findLine().text()).toBe( + 'My HTTP url: http://example.com and my HTTPS url: https://example.com', + ); + expect(findLinksAt(0).attributes().href).toBe(httpUrl); + expect(findLinksAt(1).attributes().href).toBe(httpsUrl); + }); + + it('renders a link with rel nofollow and noopener', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().attributes().rel).toBe('nofollow noopener noreferrer'); + }); + + it('renders a link with corresponding styles', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().classes()).toEqual(['gl-reset-color!', 'gl-text-decoration-underline']); + }); + + it('render links surrounded by text', () => { + createComponent( + mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl} are here.` }), + ); + expect(findLine().text()).toBe( + 'My HTTP url: http://example.com and my HTTPS url: https://example.com are here.', + ); + expect(findLinksAt(0).attributes().href).toBe(httpUrl); + expect(findLinksAt(1).attributes().href).toBe(httpsUrl); + }); + + const jshref = 'javascript:doEvil();'; // eslint-disable-line no-script-url + + test.each` + type | text + ${'js'} | ${jshref} + ${'file'} | ${'file:///a-file'} + ${'ftp'} | ${'ftp://example.com/file'} + ${'email'} | ${'email@example.com'} + ${'no scheme'} | ${'example.com/page'} + `('does not render a $type link', ({ text }) => { + createComponent(mockProps({ text })); + expect(findLink().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js index 48788df0c93..1d4be2fb81e 100644 --- a/spec/frontend/jobs/components/sidebar_spec.js +++ b/spec/frontend/jobs/components/sidebar_spec.js @@ -1,167 +1,166 @@ -import Vue from 'vue'; -import sidebarDetailsBlock from '~/jobs/components/sidebar.vue'; +import { shallowMount } from '@vue/test-utils'; +import Sidebar, { forwardDeploymentFailureModalId } from '~/jobs/components/sidebar.vue'; +import StagesDropdown from '~/jobs/components/stages_dropdown.vue'; +import JobsContainer from '~/jobs/components/jobs_container.vue'; +import JobRetryForwardDeploymentModal from '~/jobs/components/job_retry_forward_deployment_modal.vue'; +import JobRetryButton from '~/jobs/components/job_sidebar_retry_button.vue'; import createStore from '~/jobs/store'; import job, { jobsInStage } from '../mock_data'; -import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; -import { trimText } from '../../helpers/text_helper'; +import { extendedWrapper } from '../../helpers/vue_test_utils_helper'; describe('Sidebar details block', () => { - const SidebarComponent = Vue.extend(sidebarDetailsBlock); - let vm; let store; + let wrapper; - beforeEach(() => { + const forwardDeploymentFailure = 'forward_deployment_failure'; + const findModal = () => wrapper.find(JobRetryForwardDeploymentModal); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); + const findRetryButton = () => wrapper.find(JobRetryButton); + const findTerminalLink = () => wrapper.findByTestId('terminal-link'); + + const createWrapper = ({ props = {} } = {}) => { store = createStore(); - }); + wrapper = extendedWrapper( + shallowMount(Sidebar, { + ...props, + store, + }), + ); + }; afterEach(() => { - vm.$destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); describe('when there is no retry path retry', () => { - it('should not render a retry button', () => { - const copy = { ...job }; - delete copy.retry_path; - - store.dispatch('receiveJobSuccess', copy); - vm = mountComponentWithStore(SidebarComponent, { - store, - }); + it('should not render a retry button', async () => { + createWrapper(); + const copy = { ...job, retry_path: null }; + await store.dispatch('receiveJobSuccess', copy); - expect(vm.$el.querySelector('.js-retry-button')).toBeNull(); + expect(findRetryButton().exists()).toBe(false); }); }); describe('without terminal path', () => { - it('does not render terminal link', () => { - store.dispatch('receiveJobSuccess', job); - vm = mountComponentWithStore(SidebarComponent, { store }); + it('does not render terminal link', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', job); - expect(vm.$el.querySelector('.js-terminal-link')).toBeNull(); + expect(findTerminalLink().exists()).toBe(false); }); }); describe('with terminal path', () => { - it('renders terminal link', () => { - store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' }); - vm = mountComponentWithStore(SidebarComponent, { - store, - }); + it('renders terminal link', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' }); - expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull(); + expect(findTerminalLink().exists()).toBe(true); }); }); - beforeEach(() => { - store.dispatch('receiveJobSuccess', job); - vm = mountComponentWithStore(SidebarComponent, { store }); - }); - describe('actions', () => { - it('should render link to new issue', () => { - expect(vm.$el.querySelector('[data-testid="job-new-issue"]').getAttribute('href')).toEqual( - job.new_issue_path, - ); + beforeEach(() => { + createWrapper(); + return store.dispatch('receiveJobSuccess', job); + }); - expect(vm.$el.querySelector('[data-testid="job-new-issue"]').textContent.trim()).toEqual( - 'New issue', - ); + it('should render link to new issue', () => { + expect(findNewIssueButton().attributes('href')).toBe(job.new_issue_path); + expect(findNewIssueButton().text()).toBe('New issue'); }); - it('should render link to retry job', () => { - expect(vm.$el.querySelector('.js-retry-button').getAttribute('href')).toEqual(job.retry_path); + it('should render the retry button', () => { + expect(findRetryButton().props('href')).toBe(job.retry_path); }); it('should render link to cancel job', () => { - expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path); + expect(findCancelButton().text()).toMatch('Cancel'); + expect(findCancelButton().attributes('href')).toBe(job.cancel_path); }); }); - describe('information', () => { - it('should render job duration', () => { - expect(trimText(vm.$el.querySelector('.js-job-duration').textContent)).toEqual( - 'Duration: 6 seconds', - ); - }); - - it('should render erased date', () => { - expect(trimText(vm.$el.querySelector('.js-job-erased').textContent)).toEqual( - 'Erased: 3 weeks ago', - ); - }); - - it('should render finished date', () => { - expect(trimText(vm.$el.querySelector('.js-job-finished').textContent)).toEqual( - 'Finished: 3 weeks ago', - ); - }); - - it('should render queued date', () => { - expect(trimText(vm.$el.querySelector('.js-job-queued').textContent)).toEqual( - 'Queued: 9 seconds', + describe('forward deployment failure', () => { + describe('when the relevant data is missing', () => { + it.each` + retryPath | failureReason + ${null} | ${null} + ${''} | ${''} + ${job.retry_path} | ${''} + ${''} | ${forwardDeploymentFailure} + ${job.retry_path} | ${'unmet_prerequisites'} + `( + 'should not render the modal when path and failure are $retryPath, $failureReason', + async ({ retryPath, failureReason }) => { + createWrapper(); + await store.dispatch('receiveJobSuccess', { + ...job, + failure_reason: failureReason, + retry_path: retryPath, + }); + expect(findModal().exists()).toBe(false); + }, ); }); - it('should render runner ID', () => { - expect(trimText(vm.$el.querySelector('.js-job-runner').textContent)).toEqual( - 'Runner: local ci runner (#1)', - ); - }); + describe('when there is the relevant error', () => { + beforeEach(() => { + createWrapper(); + return store.dispatch('receiveJobSuccess', { + ...job, + failure_reason: forwardDeploymentFailure, + }); + }); - it('should render timeout information', () => { - expect(trimText(vm.$el.querySelector('.js-job-timeout').textContent)).toEqual( - 'Timeout: 1m 40s (from runner)', - ); - }); + it('should render the modal', () => { + expect(findModal().exists()).toBe(true); + }); - it('should render coverage', () => { - expect(trimText(vm.$el.querySelector('.js-job-coverage').textContent)).toEqual( - 'Coverage: 20%', - ); - }); + it('should provide the modal id to the button and modal', () => { + expect(findRetryButton().props('modalId')).toBe(forwardDeploymentFailureModalId); + expect(findModal().props('modalId')).toBe(forwardDeploymentFailureModalId); + }); - it('should render tags', () => { - expect(trimText(vm.$el.querySelector('.js-job-tags').textContent)).toEqual('Tags: tag'); + it('should provide the retry path to the button and modal', () => { + expect(findRetryButton().props('href')).toBe(job.retry_path); + expect(findModal().props('href')).toBe(job.retry_path); + }); }); }); describe('stages dropdown', () => { beforeEach(() => { - store.dispatch('receiveJobSuccess', job); + createWrapper(); + return store.dispatch('receiveJobSuccess', { ...job, stage: 'aStage' }); }); describe('with stages', () => { - beforeEach(() => { - vm = mountComponentWithStore(SidebarComponent, { store }); - }); - it('renders value provided as selectedStage as selected', () => { - expect(vm.$el.querySelector('.js-selected-stage').textContent.trim()).toEqual( - vm.selectedStage, - ); + expect(wrapper.find(StagesDropdown).props('selectedStage')).toBe('aStage'); }); }); describe('without jobs for stages', () => { - beforeEach(() => { - store.dispatch('receiveJobSuccess', job); - vm = mountComponentWithStore(SidebarComponent, { store }); - }); + beforeEach(() => store.dispatch('receiveJobSuccess', job)); - it('does not render job container', () => { - expect(vm.$el.querySelector('.js-jobs-container')).toBeNull(); + it('does not render jobs container', () => { + expect(wrapper.find(JobsContainer).exists()).toBe(false); }); }); describe('with jobs for stages', () => { - beforeEach(() => { - store.dispatch('receiveJobSuccess', job); - store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses); - vm = mountComponentWithStore(SidebarComponent, { store }); + beforeEach(async () => { + await store.dispatch('receiveJobSuccess', job); + await store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses); }); it('renders list of jobs', () => { - expect(vm.$el.querySelector('.js-jobs-container')).not.toBeNull(); + expect(wrapper.find(JobsContainer).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/lib/utils/apollo_startup_js_link_spec.js b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js new file mode 100644 index 00000000000..faead3ff8fe --- /dev/null +++ b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js @@ -0,0 +1,375 @@ +import { ApolloLink, Observable } from 'apollo-link'; +import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; + +describe('StartupJSLink', () => { + const FORWARDED_RESPONSE = { data: 'FORWARDED_RESPONSE' }; + + const STARTUP_JS_RESPONSE = { data: 'STARTUP_JS_RESPONSE' }; + const OPERATION_NAME = 'startupJSQuery'; + const STARTUP_JS_QUERY = `query ${OPERATION_NAME}($id: Int = 3){ + name + id + }`; + + const STARTUP_JS_RESPONSE_TWO = { data: 'STARTUP_JS_RESPONSE_TWO' }; + const OPERATION_NAME_TWO = 'startupJSQueryTwo'; + const STARTUP_JS_QUERY_TWO = `query ${OPERATION_NAME_TWO}($id: Int = 3){ + id + name + }`; + + const ERROR_RESPONSE = { + data: { + user: null, + }, + errors: [ + { + path: ['user'], + locations: [{ line: 2, column: 3 }], + extensions: { + message: 'Object not found', + type: 2, + }, + }, + ], + }; + + let startupLink; + let link; + + function mockFetchCall(status = 200, response = STARTUP_JS_RESPONSE) { + const p = { + ok: status >= 200 && status < 300, + status, + headers: new Headers({ 'Content-Type': 'application/json' }), + statusText: `MOCK-FETCH ${status}`, + clone: () => p, + json: () => Promise.resolve(response), + }; + return Promise.resolve(p); + } + + function mockOperation({ operationName = OPERATION_NAME, variables = { id: 3 } } = {}) { + return { operationName, variables, setContext: () => {} }; + } + + const setupLink = () => { + startupLink = new StartupJSLink(); + link = ApolloLink.from([startupLink, new ApolloLink(() => Observable.of(FORWARDED_RESPONSE))]); + }; + + it('forwards requests if no calls are set up', done => { + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls).toBe(null); + expect(startupLink.request).toEqual(StartupJSLink.noopRequest); + done(); + }); + }); + + it('forwards requests if the operation is not pre-loaded', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { id: 3 }, + }, + ], + }; + setupLink(); + link.request(mockOperation({ operationName: 'notLoaded' })).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(1); + done(); + }); + }); + + describe('variable match errors: ', () => { + it('forwards requests if the variables are not matching', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { id: 'NOT_MATCHING' }, + }, + ], + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('forwards requests if more variables are set in the operation', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + }, + ], + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('forwards requests if less variables are set in the operation', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { id: 3, name: 'tanuki' }, + }, + ], + }; + setupLink(); + link.request(mockOperation({ variables: { id: 3 } })).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('forwards requests if different variables are set', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { name: 'tanuki' }, + }, + ], + }; + setupLink(); + link.request(mockOperation({ variables: { id: 3 } })).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('forwards requests if array variables have a different order', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { id: [3, 4] }, + }, + ], + }; + setupLink(); + link.request(mockOperation({ variables: { id: [4, 3] } })).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + }); + + describe('error handling', () => { + it('forwards the call if the fetchCall is failing with a HTTP Error', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(404), + query: STARTUP_JS_QUERY, + variables: { id: 3 }, + }, + ], + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('forwards the call if it errors (e.g. failing JSON)', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: Promise.reject(new Error('Parsing failed')), + query: STARTUP_JS_QUERY, + variables: { id: 3 }, + }, + ], + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('forwards the call if the response contains an error', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(200, ERROR_RESPONSE), + query: STARTUP_JS_QUERY, + variables: { id: 3 }, + }, + ], + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it("forwards the call if the response doesn't contain a data object", done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(200, { 'no-data': 'yay' }), + query: STARTUP_JS_QUERY, + variables: { id: 3 }, + }, + ], + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(FORWARDED_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + }); + + it('resolves the request if the operation is matching', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { id: 3 }, + }, + ], + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(STARTUP_JS_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('resolves the request exactly once', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { id: 3 }, + }, + ], + }; + setupLink(); + link.request(mockOperation()).subscribe(result => { + expect(result).toEqual(STARTUP_JS_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + link.request(mockOperation()).subscribe(result2 => { + expect(result2).toEqual(FORWARDED_RESPONSE); + done(); + }); + }); + }); + + it('resolves the request if the variables have a different order', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { id: 3, name: 'foo' }, + }, + ], + }; + setupLink(); + link.request(mockOperation({ variables: { name: 'foo', id: 3 } })).subscribe(result => { + expect(result).toEqual(STARTUP_JS_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('resolves the request if the variables have undefined values', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { name: 'foo' }, + }, + ], + }; + setupLink(); + link + .request(mockOperation({ variables: { name: 'foo', undef: undefined } })) + .subscribe(result => { + expect(result).toEqual(STARTUP_JS_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('resolves the request if the variables are of an array format', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { id: [3, 4] }, + }, + ], + }; + setupLink(); + link.request(mockOperation({ variables: { id: [3, 4] } })).subscribe(result => { + expect(result).toEqual(STARTUP_JS_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + + it('resolves multiple requests correctly', done => { + window.gl = { + startup_graphql_calls: [ + { + fetchCall: mockFetchCall(), + query: STARTUP_JS_QUERY, + variables: { id: 3 }, + }, + { + fetchCall: mockFetchCall(200, STARTUP_JS_RESPONSE_TWO), + query: STARTUP_JS_QUERY_TWO, + variables: { id: 3 }, + }, + ], + }; + setupLink(); + link.request(mockOperation({ operationName: OPERATION_NAME_TWO })).subscribe(result => { + expect(result).toEqual(STARTUP_JS_RESPONSE_TWO); + expect(startupLink.startupCalls.size).toBe(1); + link.request(mockOperation({ operationName: OPERATION_NAME })).subscribe(result2 => { + expect(result2).toEqual(STARTUP_JS_RESPONSE); + expect(startupLink.startupCalls.size).toBe(0); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index effc446d846..09eb362c77e 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -959,6 +959,25 @@ describe('common_utils', () => { }); }); + describe('roundDownFloat', () => { + it('Rounds down decimal places of a float number with provided precision', () => { + expect(commonUtils.roundDownFloat(3.141592, 3)).toBe(3.141); + }); + + it('Rounds down a float number to a whole number when provided precision is zero', () => { + expect(commonUtils.roundDownFloat(3.141592, 0)).toBe(3); + expect(commonUtils.roundDownFloat(3.9, 0)).toBe(3); + }); + + it('Rounds down float number to nearest 0, 10, 100, 1000 and so on when provided precision is below 0', () => { + expect(commonUtils.roundDownFloat(34567.14159, -1)).toBeCloseTo(34560); + expect(commonUtils.roundDownFloat(34567.14159, -2)).toBeCloseTo(34500); + expect(commonUtils.roundDownFloat(34567.14159, -3)).toBeCloseTo(34000); + expect(commonUtils.roundDownFloat(34567.14159, -4)).toBeCloseTo(30000); + expect(commonUtils.roundDownFloat(34567.14159, -5)).toBeCloseTo(0); + }); + }); + describe('searchBy', () => { const searchSpace = { iid: 1, diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index b0b0b028761..6092b44720f 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -643,16 +643,15 @@ describe('localTimeAgo', () => { }); it.each` - timeagoArg | title | dataOriginalTitle - ${false} | ${'some time'} | ${null} - ${true} | ${''} | ${'Feb 18, 2020 10:22pm GMT+0000'} - `('converts $seconds seconds to $approximation', ({ timeagoArg, title, dataOriginalTitle }) => { + timeagoArg | title + ${false} | ${'some time'} + ${true} | ${'Feb 18, 2020 10:22pm GMT+0000'} + `('converts $seconds seconds to $approximation', ({ timeagoArg, title }) => { const element = document.querySelector('time'); datetimeUtility.localTimeAgo($(element), timeagoArg); jest.runAllTimers(); - expect(element.getAttribute('data-original-title')).toBe(dataOriginalTitle); expect(element.getAttribute('title')).toBe(title); }); }); diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index d918016a5f4..f5c2a797df5 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -3,6 +3,8 @@ import { canScrollUp, canScrollDown, parseBooleanDataAttributes, + isElementVisible, + isElementHidden, } from '~/lib/utils/dom_utils'; const TEST_MARGIN = 5; @@ -160,4 +162,35 @@ describe('DOM Utils', () => { }); }); }); + + describe.each` + offsetWidth | offsetHeight | clientRectsLength | visible + ${0} | ${0} | ${0} | ${false} + ${1} | ${0} | ${0} | ${true} + ${0} | ${1} | ${0} | ${true} + ${0} | ${0} | ${1} | ${true} + `( + 'isElementVisible and isElementHidden', + ({ offsetWidth, offsetHeight, clientRectsLength, visible }) => { + const element = { + offsetWidth, + offsetHeight, + getClientRects: () => new Array(clientRectsLength), + }; + + const paramDescription = `offsetWidth=${offsetWidth}, offsetHeight=${offsetHeight}, and getClientRects().length=${clientRectsLength}`; + + describe('isElementVisible', () => { + it(`returns ${visible} when ${paramDescription}`, () => { + expect(isElementVisible(element)).toBe(visible); + }); + }); + + describe('isElementHidden', () => { + it(`returns ${!visible} when ${paramDescription}`, () => { + expect(isElementHidden(element)).toBe(!visible); + }); + }); + }, + ); }); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index f600f2bcd55..2f8f1092612 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -1,6 +1,5 @@ import { formatRelevantDigits, - bytesToKB, bytesToKiB, bytesToMiB, bytesToGiB, @@ -55,16 +54,6 @@ describe('Number Utils', () => { }); }); - describe('bytesToKB', () => { - it.each` - input | output - ${1000} | ${1} - ${1024} | ${1.024} - `('returns $output KB for $input bytes', ({ input, output }) => { - expect(bytesToKB(input)).toBe(output); - }); - }); - describe('bytesToKiB', () => { it('calculates KiB for the given bytes', () => { expect(bytesToKiB(1024)).toEqual(1); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 6fef5f6b63c..d7cedb939d2 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -325,4 +325,19 @@ describe('text_utility', () => { expect(textUtils.hasContent(txt)).toEqual(result); }); }); + + describe('isValidSha1Hash', () => { + const validSha1Hash = '92d10c15'; + const stringOver40 = new Array(42).join('a'); + + it.each` + hash | valid + ${validSha1Hash} | ${true} + ${'__characters'} | ${false} + ${'abc'} | ${false} + ${stringOver40} | ${false} + `(`returns $valid for $hash`, ({ hash, valid }) => { + expect(textUtils.isValidSha1Hash(hash)).toBe(valid); + }); + }); }); diff --git a/spec/frontend/milestones/milestone_combobox_spec.js b/spec/frontend/milestones/milestone_combobox_spec.js new file mode 100644 index 00000000000..047484f117f --- /dev/null +++ b/spec/frontend/milestones/milestone_combobox_spec.js @@ -0,0 +1,518 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { ENTER_KEY } from '~/lib/utils/keys'; +import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; +import { projectMilestones, groupMilestones } from './mock_data'; +import createStore from '~/milestones/stores/'; + +const extraLinks = [ + { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' }, + { text: 'Manage milestones', url: '/h5bp/html5-boilerplate/-/milestones' }, +]; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Milestone combobox component', () => { + const projectId = '8'; + const groupId = '24'; + const groupMilestonesAvailable = true; + const X_TOTAL_HEADER = 'x-total'; + + let wrapper; + let projectMilestonesApiCallSpy; + let groupMilestonesApiCallSpy; + let searchApiCallSpy; + + const createComponent = (props = {}, attrs = {}) => { + wrapper = mount(MilestoneCombobox, { + propsData: { + projectId, + groupId, + groupMilestonesAvailable, + extraLinks, + value: [], + ...props, + }, + attrs, + listeners: { + // simulate a parent component v-model binding + input: selectedMilestone => { + wrapper.setProps({ value: selectedMilestone }); + }, + }, + stubs: { + GlSearchBoxByType: true, + }, + localVue, + store: createStore(), + }); + }; + + beforeEach(() => { + const mock = new MockAdapter(axios); + gon.api_version = 'v4'; + + projectMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]); + + groupMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, groupMilestones, { [X_TOTAL_HEADER]: '6' }]); + + searchApiCallSpy = jest + .fn() + .mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]); + + mock + .onGet(`/api/v4/projects/${projectId}/milestones`) + .reply(config => projectMilestonesApiCallSpy(config)); + + mock + .onGet(`/api/v4/groups/${groupId}/milestones`) + .reply(config => groupMilestonesApiCallSpy(config)); + + mock.onGet(`/api/v4/projects/${projectId}/search`).reply(config => searchApiCallSpy(config)); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + // + // Finders + // + const findButtonContent = () => wrapper.find('[data-testid="milestone-combobox-button-content"]'); + + const findNoResults = () => wrapper.find('[data-testid="milestone-combobox-no-results"]'); + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const findSearchBox = () => wrapper.find(GlSearchBoxByType); + + const findProjectMilestonesSection = () => + wrapper.find('[data-testid="project-milestones-section"]'); + const findProjectMilestonesDropdownItems = () => + findProjectMilestonesSection().findAll(GlDropdownItem); + const findFirstProjectMilestonesDropdownItem = () => findProjectMilestonesDropdownItems().at(0); + + const findGroupMilestonesSection = () => wrapper.find('[data-testid="group-milestones-section"]'); + const findGroupMilestonesDropdownItems = () => + findGroupMilestonesSection().findAll(GlDropdownItem); + const findFirstGroupMilestonesDropdownItem = () => findGroupMilestonesDropdownItems().at(0); + + // + // Expecters + // + const projectMilestoneSectionContainsErrorMessage = () => { + const projectMilestoneSection = findProjectMilestonesSection(); + + return projectMilestoneSection + .text() + .includes(s__('MilestoneCombobox|An error occurred while searching for milestones')); + }; + + const groupMilestoneSectionContainsErrorMessage = () => { + const groupMilestoneSection = findGroupMilestonesSection(); + + return groupMilestoneSection + .text() + .includes(s__('MilestoneCombobox|An error occurred while searching for milestones')); + }; + + // + // Convenience methods + // + const updateQuery = newQuery => { + findSearchBox().vm.$emit('input', newQuery); + }; + + const selectFirstProjectMilestone = () => { + findFirstProjectMilestonesDropdownItem().vm.$emit('click'); + }; + + const selectFirstGroupMilestone = () => { + findFirstGroupMilestonesDropdownItem().vm.$emit('click'); + }; + + const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) => + axios.waitForAll().then(() => { + if (andClearMocks) { + projectMilestonesApiCallSpy.mockClear(); + groupMilestonesApiCallSpy.mockClear(); + } + }); + + describe('initialization behavior', () => { + beforeEach(createComponent); + + it('initializes the dropdown with milestones when mounted', () => { + return waitForRequests().then(() => { + expect(projectMilestonesApiCallSpy).toHaveBeenCalledTimes(1); + expect(groupMilestonesApiCallSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('shows a spinner while network requests are in progress', () => { + expect(findLoadingIcon().exists()).toBe(true); + + return waitForRequests().then(() => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + it('shows additional links', () => { + const links = wrapper.findAll('[data-testid="milestone-combobox-extra-links"]'); + links.wrappers.forEach((item, idx) => { + expect(item.text()).toBe(extraLinks[idx].text); + expect(item.attributes('href')).toBe(extraLinks[idx].url); + }); + }); + }); + + describe('post-initialization behavior', () => { + describe('when the parent component provides an `id` binding', () => { + const id = '8'; + + beforeEach(() => { + createComponent({}, { id }); + + return waitForRequests(); + }); + + it('adds the provided ID to the GlDropdown instance', () => { + expect(wrapper.attributes().id).toBe(id); + }); + }); + + describe('when milestones are pre-selected', () => { + beforeEach(() => { + createComponent({ value: projectMilestones }); + + return waitForRequests(); + }); + + it('renders the pre-selected milestones', () => { + expect(findButtonContent().text()).toBe('v0.1 + 5 more'); + }); + }); + + describe('when the search query is updated', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests({ andClearMocks: true }); + }); + + it('requeries the search when the search query is updated', () => { + updateQuery('v1.2.3'); + + return waitForRequests().then(() => { + expect(searchApiCallSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when the Enter is pressed', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests({ andClearMocks: true }); + }); + + it('requeries the search when Enter is pressed', () => { + findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + + return waitForRequests().then(() => { + expect(searchApiCallSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when no results are found', () => { + beforeEach(() => { + projectMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + groupMilestonesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + createComponent(); + + return waitForRequests(); + }); + + describe('when the search query is empty', () => { + it('renders a "no results" message', () => { + expect(findNoResults().text()).toBe(s__('MilestoneCombobox|No matching results')); + }); + }); + }); + + describe('project milestones', () => { + describe('when the project milestones search returns results', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders the project milestones section in the dropdown', () => { + expect(findProjectMilestonesSection().exists()).toBe(true); + }); + + it('renders the "Project milestones" heading with a total number indicator', () => { + expect( + findProjectMilestonesSection() + .find('[data-testid="milestone-results-section-header"]') + .text(), + ).toBe('Project milestones 6'); + }); + + it("does not render an error message in the project milestone section's body", () => { + expect(projectMilestoneSectionContainsErrorMessage()).toBe(false); + }); + + it('renders each project milestones as a selectable item', () => { + const dropdownItems = findProjectMilestonesDropdownItems(); + + projectMilestones.forEach((milestone, i) => { + expect(dropdownItems.at(i).text()).toBe(milestone.title); + }); + }); + }); + + describe('when the project milestones search returns no results', () => { + beforeEach(() => { + projectMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + createComponent(); + + return waitForRequests(); + }); + + it('does not render the project milestones section in the dropdown', () => { + expect(findProjectMilestonesSection().exists()).toBe(false); + }); + }); + + describe('when the project milestones search returns an error', () => { + beforeEach(() => { + projectMilestonesApiCallSpy = jest.fn().mockReturnValue([500]); + searchApiCallSpy = jest.fn().mockReturnValue([500]); + + createComponent({ value: [] }); + + return waitForRequests(); + }); + + it('renders the project milestones section in the dropdown', () => { + expect(findProjectMilestonesSection().exists()).toBe(true); + }); + + it("renders an error message in the project milestones section's body", () => { + expect(projectMilestoneSectionContainsErrorMessage()).toBe(true); + }); + }); + + describe('selection', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders a checkmark by the selected item', async () => { + selectFirstProjectMilestone(); + + await localVue.nextTick(); + + expect( + findFirstProjectMilestonesDropdownItem() + .find('span') + .classes('selected-item'), + ).toBe(false); + + selectFirstProjectMilestone(); + + await localVue.nextTick(); + + expect( + findFirstProjectMilestonesDropdownItem() + .find('span') + .classes('selected-item'), + ).toBe(true); + }); + + describe('when a project milestones is selected', () => { + beforeEach(() => { + createComponent(); + projectMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, [{ title: 'v1.0' }], { [X_TOTAL_HEADER]: '1' }]); + + return waitForRequests(); + }); + + it("displays the project milestones name in the dropdown's button", async () => { + selectFirstProjectMilestone(); + await localVue.nextTick(); + + expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone')); + + selectFirstProjectMilestone(); + + await localVue.nextTick(); + expect(findButtonContent().text()).toBe('v1.0'); + }); + + it('updates the v-model binding with the project milestone title', () => { + expect(wrapper.vm.value).toEqual([]); + + selectFirstProjectMilestone(); + + expect(wrapper.vm.value).toEqual(['v1.0']); + }); + }); + }); + }); + + describe('group milestones', () => { + describe('when the group milestones search returns results', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders the group milestones section in the dropdown', () => { + expect(findGroupMilestonesSection().exists()).toBe(true); + }); + + it('renders the "Group milestones" heading with a total number indicator', () => { + expect( + findGroupMilestonesSection() + .find('[data-testid="milestone-results-section-header"]') + .text(), + ).toBe('Group milestones 6'); + }); + + it("does not render an error message in the group milestone section's body", () => { + expect(groupMilestoneSectionContainsErrorMessage()).toBe(false); + }); + + it('renders each group milestones as a selectable item', () => { + const dropdownItems = findGroupMilestonesDropdownItems(); + + groupMilestones.forEach((milestone, i) => { + expect(dropdownItems.at(i).text()).toBe(milestone.title); + }); + }); + }); + + describe('when the group milestones search returns no results', () => { + beforeEach(() => { + groupMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + createComponent(); + + return waitForRequests(); + }); + + it('does not render the group milestones section in the dropdown', () => { + expect(findGroupMilestonesSection().exists()).toBe(false); + }); + }); + + describe('when the group milestones search returns an error', () => { + beforeEach(() => { + groupMilestonesApiCallSpy = jest.fn().mockReturnValue([500]); + searchApiCallSpy = jest.fn().mockReturnValue([500]); + + createComponent({ value: [] }); + + return waitForRequests(); + }); + + it('renders the group milestones section in the dropdown', () => { + expect(findGroupMilestonesSection().exists()).toBe(true); + }); + + it("renders an error message in the group milestones section's body", () => { + expect(groupMilestoneSectionContainsErrorMessage()).toBe(true); + }); + }); + + describe('selection', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders a checkmark by the selected item', async () => { + selectFirstGroupMilestone(); + + await localVue.nextTick(); + + expect( + findFirstGroupMilestonesDropdownItem() + .find('span') + .classes('selected-item'), + ).toBe(false); + + selectFirstGroupMilestone(); + + await localVue.nextTick(); + + expect( + findFirstGroupMilestonesDropdownItem() + .find('span') + .classes('selected-item'), + ).toBe(true); + }); + + describe('when a group milestones is selected', () => { + beforeEach(() => { + createComponent(); + groupMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, [{ title: 'group-v1.0' }], { [X_TOTAL_HEADER]: '1' }]); + + return waitForRequests(); + }); + + it("displays the group milestones name in the dropdown's button", async () => { + selectFirstGroupMilestone(); + await localVue.nextTick(); + + expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone')); + + selectFirstGroupMilestone(); + + await localVue.nextTick(); + expect(findButtonContent().text()).toBe('group-v1.0'); + }); + + it('updates the v-model binding with the group milestone title', () => { + expect(wrapper.vm.value).toEqual([]); + + selectFirstGroupMilestone(); + + expect(wrapper.vm.value).toEqual(['group-v1.0']); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/milestones/mock_data.js b/spec/frontend/milestones/mock_data.js index c64eeeba663..71fbfe54141 100644 --- a/spec/frontend/milestones/mock_data.js +++ b/spec/frontend/milestones/mock_data.js @@ -1,4 +1,4 @@ -export const milestones = [ +export const projectMilestones = [ { id: 41, iid: 6, @@ -79,4 +79,94 @@ export const milestones = [ }, ]; -export default milestones; +export const groupMilestones = [ + { + id: 141, + iid: 16, + project_id: 8, + group_id: 12, + title: 'group-v0.1', + description: '', + state: 'active', + created_at: '2020-04-04T01:30:40.051Z', + updated_at: '2020-04-04T01:30:40.051Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6', + }, + { + id: 140, + iid: 15, + project_id: 8, + group_id: 12, + title: 'group-v4.0', + description: 'Laboriosam nisi sapiente dolores et magnam nobis ad earum.', + state: 'closed', + created_at: '2020-01-13T19:39:15.191Z', + updated_at: '2020-01-13T19:39:15.191Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/5', + }, + { + id: 139, + iid: 14, + project_id: 8, + group_id: 12, + title: 'group-v3.0', + description: 'Necessitatibus illo alias et repellat dolorum assumenda ut.', + state: 'closed', + created_at: '2020-01-13T19:39:15.176Z', + updated_at: '2020-01-13T19:39:15.176Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/4', + }, + { + id: 138, + iid: 13, + project_id: 8, + group_id: 12, + title: 'group-v2.0', + description: 'Doloribus qui repudiandae iste sit.', + state: 'closed', + created_at: '2020-01-13T19:39:15.161Z', + updated_at: '2020-01-13T19:39:15.161Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/3', + }, + { + id: 137, + iid: 12, + project_id: 8, + group_id: 12, + title: 'group-v1.0', + description: 'Illo sint odio officia ea.', + state: 'closed', + created_at: '2020-01-13T19:39:15.146Z', + updated_at: '2020-01-13T19:39:15.146Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/2', + }, + { + id: 136, + iid: 11, + project_id: 8, + group_id: 12, + title: 'group-v0.0', + description: 'Sed quae facilis deleniti at delectus assumenda nobis veritatis.', + state: 'active', + created_at: '2020-01-13T19:39:15.127Z', + updated_at: '2020-01-13T19:39:15.127Z', + due_date: null, + start_date: null, + web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/1', + }, +]; + +export default { + projectMilestones, + groupMilestones, +}; diff --git a/spec/frontend/milestones/project_milestone_combobox_spec.js b/spec/frontend/milestones/project_milestone_combobox_spec.js deleted file mode 100644 index 60d68aa5816..00000000000 --- a/spec/frontend/milestones/project_milestone_combobox_spec.js +++ /dev/null @@ -1,186 +0,0 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; -import { ENTER_KEY } from '~/lib/utils/keys'; -import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; -import { milestones as projectMilestones } from './mock_data'; - -const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search'; -const TEST_SEARCH = 'TEST_SEARCH'; - -const extraLinks = [ - { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' }, - { text: 'Manage milestones', url: '/h5bp/html5-boilerplate/-/milestones' }, -]; - -const preselectedMilestones = []; -const projectId = '8'; - -describe('Milestone selector', () => { - let wrapper; - let mock; - - const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' }); - - const findSearchBox = () => wrapper.find(GlSearchBoxByType); - - const factory = (options = {}) => { - wrapper = shallowMount(MilestoneCombobox, { - ...options, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - gon.api_version = 'v4'; - - mock.onGet('/api/v4/projects/8/milestones').reply(200, projectMilestones); - - factory({ - propsData: { - projectId, - preselectedMilestones, - extraLinks, - }, - }); - }); - - afterEach(() => { - mock.restore(); - wrapper.destroy(); - wrapper = null; - }); - - it('renders the dropdown', () => { - expect(wrapper.find(GlDropdown)).toExist(); - }); - - it('renders additional links', () => { - const links = wrapper.findAll('[href]'); - links.wrappers.forEach((item, idx) => { - expect(item.text()).toBe(extraLinks[idx].text); - expect(item.attributes('href')).toBe(extraLinks[idx].url); - }); - }); - - describe('before results', () => { - it('should show a loading icon', () => { - const request = mock.onGet(TEST_SEARCH_ENDPOINT, { - params: { search: TEST_SEARCH, scope: 'milestones' }, - }); - - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - - return wrapper.vm.$nextTick().then(() => { - request.reply(200, []); - }); - }); - - it('should not show any dropdown items', () => { - expect(wrapper.findAll('[role="milestone option"]')).toHaveLength(0); - }); - - it('should have "No milestone" as the button text', () => { - expect(wrapper.find({ ref: 'buttonText' }).text()).toBe('No milestone'); - }); - }); - - describe('with empty results', () => { - beforeEach(() => { - mock - .onGet(TEST_SEARCH_ENDPOINT, { params: { search: TEST_SEARCH, scope: 'milestones' } }) - .reply(200, []); - findSearchBox().vm.$emit('input', TEST_SEARCH); - return axios.waitForAll(); - }); - - it('should display that no matching items are found', () => { - expect(findNoResultsMessage().exists()).toBe(true); - }); - }); - - describe('with results', () => { - let items; - beforeEach(() => { - mock - .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'v0.1', scope: 'milestones' } }) - .reply(200, [ - { - id: 41, - iid: 6, - project_id: 8, - title: 'v0.1', - description: '', - state: 'active', - created_at: '2020-04-04T01:30:40.051Z', - updated_at: '2020-04-04T01:30:40.051Z', - due_date: null, - start_date: null, - web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6', - }, - ]); - findSearchBox().vm.$emit('input', 'v0.1'); - return axios.waitForAll().then(() => { - items = wrapper.findAll('[role="milestone option"]'); - }); - }); - - it('should display one item per result', () => { - expect(items).toHaveLength(1); - }); - - it('should emit a change if an item is clicked', () => { - items.at(0).vm.$emit('click'); - expect(wrapper.emitted().change.length).toBe(1); - expect(wrapper.emitted().change[0]).toEqual([[{ title: 'v0.1' }]]); - }); - - it('should not have a selecton icon on any item', () => { - items.wrappers.forEach(item => { - expect(item.find('.selected-item').exists()).toBe(false); - }); - }); - - it('should have a selecton icon if an item is clicked', () => { - items.at(0).vm.$emit('click'); - expect(wrapper.find('.selected-item').exists()).toBe(true); - }); - - it('should not display a message about no results', () => { - expect(findNoResultsMessage().exists()).toBe(false); - }); - }); - - describe('when Enter is pressed', () => { - beforeEach(() => { - factory({ - propsData: { - projectId, - preselectedMilestones, - extraLinks, - }, - data() { - return { - searchQuery: 'TEST_SEARCH', - }; - }, - }); - - mock - .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } }) - .reply(200, []); - }); - - it('should trigger a search', async () => { - mock.resetHistory(); - - findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - - await axios.waitForAll(); - - expect(mock.history.get.length).toBe(1); - expect(mock.history.get[0].url).toBe(TEST_SEARCH_ENDPOINT); - }); - }); -}); diff --git a/spec/frontend/milestones/stores/actions_spec.js b/spec/frontend/milestones/stores/actions_spec.js index ad73d0e4238..a62b0c49a80 100644 --- a/spec/frontend/milestones/stores/actions_spec.js +++ b/spec/frontend/milestones/stores/actions_spec.js @@ -4,6 +4,7 @@ import * as actions from '~/milestones/stores/actions'; import * as types from '~/milestones/stores/mutation_types'; let mockProjectMilestonesReturnValue; +let mockGroupMilestonesReturnValue; let mockProjectSearchReturnValue; jest.mock('~/api', () => ({ @@ -13,6 +14,7 @@ jest.mock('~/api', () => ({ default: { projectMilestones: () => mockProjectMilestonesReturnValue, projectSearch: () => mockProjectSearchReturnValue, + groupMilestones: () => mockGroupMilestonesReturnValue, }, })); @@ -32,6 +34,24 @@ describe('Milestone combobox Vuex store actions', () => { }); }); + describe('setGroupId', () => { + it(`commits ${types.SET_GROUP_ID} with the new group ID`, () => { + const groupId = '123'; + testAction(actions.setGroupId, groupId, state, [ + { type: types.SET_GROUP_ID, payload: groupId }, + ]); + }); + }); + + describe('setGroupMilestonesAvailable', () => { + it(`commits ${types.SET_GROUP_MILESTONES_AVAILABLE} with the boolean indicating if group milestones are available (Premium)`, () => { + state.groupMilestonesAvailable = true; + testAction(actions.setGroupMilestonesAvailable, state.groupMilestonesAvailable, state, [ + { type: types.SET_GROUP_MILESTONES_AVAILABLE, payload: state.groupMilestonesAvailable }, + ]); + }); + }); + describe('setSelectedMilestones', () => { it(`commits ${types.SET_SELECTED_MILESTONES} with the new selected milestones name`, () => { const selectedMilestones = ['v1.2.3']; @@ -41,6 +61,14 @@ describe('Milestone combobox Vuex store actions', () => { }); }); + describe('clearSelectedMilestones', () => { + it(`commits ${types.CLEAR_SELECTED_MILESTONES} with the new selected milestones name`, () => { + testAction(actions.clearSelectedMilestones, null, state, [ + { type: types.CLEAR_SELECTED_MILESTONES }, + ]); + }); + }); + describe('toggleMilestones', () => { const selectedMilestone = 'v1.2.3'; it(`commits ${types.ADD_SELECTED_MILESTONE} with the new selected milestone name`, () => { @@ -58,19 +86,38 @@ describe('Milestone combobox Vuex store actions', () => { }); describe('search', () => { - it(`commits ${types.SET_QUERY} with the new search query`, () => { - const query = 'v1.0'; - testAction( - actions.search, - query, - state, - [{ type: types.SET_QUERY, payload: query }], - [{ type: 'searchMilestones' }], - ); + describe('when project has license to add group milestones', () => { + it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project and group milestones`, () => { + const getters = { + groupMilestonesEnabled: () => true, + }; + + const searchQuery = 'v1.0'; + testAction( + actions.search, + searchQuery, + { ...state, ...getters }, + [{ type: types.SET_SEARCH_QUERY, payload: searchQuery }], + [{ type: 'searchProjectMilestones' }, { type: 'searchGroupMilestones' }], + ); + }); + }); + + describe('when project does not have license to add group milestones', () => { + it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project milestones`, () => { + const searchQuery = 'v1.0'; + testAction( + actions.search, + searchQuery, + state, + [{ type: types.SET_SEARCH_QUERY, payload: searchQuery }], + [{ type: 'searchProjectMilestones' }], + ); + }); }); }); - describe('searchMilestones', () => { + describe('searchProjectMilestones', () => { describe('when the search is successful', () => { const projectSearchApiResponse = { data: [{ title: 'v1.0' }] }; @@ -79,7 +126,7 @@ describe('Milestone combobox Vuex store actions', () => { }); it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => { - return testAction(actions.searchMilestones, undefined, state, [ + return testAction(actions.searchProjectMilestones, undefined, state, [ { type: types.REQUEST_START }, { type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectSearchApiResponse }, { type: types.REQUEST_FINISH }, @@ -95,7 +142,7 @@ describe('Milestone combobox Vuex store actions', () => { }); it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => { - return testAction(actions.searchMilestones, undefined, state, [ + return testAction(actions.searchProjectMilestones, undefined, state, [ { type: types.REQUEST_START }, { type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error }, { type: types.REQUEST_FINISH }, @@ -104,7 +151,71 @@ describe('Milestone combobox Vuex store actions', () => { }); }); + describe('searchGroupMilestones', () => { + describe('when the search is successful', () => { + const groupSearchApiResponse = { data: [{ title: 'group-v1.0' }] }; + + beforeEach(() => { + mockGroupMilestonesReturnValue = Promise.resolve(groupSearchApiResponse); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchGroupMilestones, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_GROUP_MILESTONES_SUCCESS, payload: groupSearchApiResponse }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + + describe('when the search fails', () => { + const error = new Error('Something went wrong!'); + + beforeEach(() => { + mockGroupMilestonesReturnValue = Promise.reject(error); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchGroupMilestones, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_GROUP_MILESTONES_ERROR, payload: error }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + }); + describe('fetchMilestones', () => { + describe('when project has license to add group milestones', () => { + it(`dispatchs fetchProjectMilestones and fetchGroupMilestones`, () => { + const getters = { + groupMilestonesEnabled: () => true, + }; + + testAction( + actions.fetchMilestones, + undefined, + { ...state, ...getters }, + [], + [{ type: 'fetchProjectMilestones' }, { type: 'fetchGroupMilestones' }], + ); + }); + }); + + describe('when project does not have license to add group milestones', () => { + it(`dispatchs fetchProjectMilestones`, () => { + testAction( + actions.fetchMilestones, + undefined, + state, + [], + [{ type: 'fetchProjectMilestones' }], + ); + }); + }); + }); + + describe('fetchProjectMilestones', () => { describe('when the fetch is successful', () => { const projectMilestonesApiResponse = { data: [{ title: 'v1.0' }] }; @@ -113,7 +224,7 @@ describe('Milestone combobox Vuex store actions', () => { }); it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => { - return testAction(actions.fetchMilestones, undefined, state, [ + return testAction(actions.fetchProjectMilestones, undefined, state, [ { type: types.REQUEST_START }, { type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectMilestonesApiResponse }, { type: types.REQUEST_FINISH }, @@ -129,7 +240,7 @@ describe('Milestone combobox Vuex store actions', () => { }); it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => { - return testAction(actions.fetchMilestones, undefined, state, [ + return testAction(actions.fetchProjectMilestones, undefined, state, [ { type: types.REQUEST_START }, { type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error }, { type: types.REQUEST_FINISH }, @@ -137,4 +248,38 @@ describe('Milestone combobox Vuex store actions', () => { }); }); }); + + describe('fetchGroupMilestones', () => { + describe('when the fetch is successful', () => { + const groupMilestonesApiResponse = { data: [{ title: 'group-v1.0' }] }; + + beforeEach(() => { + mockGroupMilestonesReturnValue = Promise.resolve(groupMilestonesApiResponse); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.fetchGroupMilestones, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_GROUP_MILESTONES_SUCCESS, payload: groupMilestonesApiResponse }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + + describe('when the fetch fails', () => { + const error = new Error('Something went wrong!'); + + beforeEach(() => { + mockGroupMilestonesReturnValue = Promise.reject(error); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.fetchGroupMilestones, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_GROUP_MILESTONES_ERROR, payload: error }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + }); }); diff --git a/spec/frontend/milestones/stores/getter_spec.js b/spec/frontend/milestones/stores/getter_spec.js index df7c3d28e67..4a6116b642c 100644 --- a/spec/frontend/milestones/stores/getter_spec.js +++ b/spec/frontend/milestones/stores/getter_spec.js @@ -12,4 +12,22 @@ describe('Milestone comboxbox Vuex store getters', () => { expect(getters.isLoading({ requestCount })).toBe(isLoading); }); }); + + describe('groupMilestonesEnabled', () => { + it.each` + groupId | groupMilestonesAvailable | groupMilestonesEnabled + ${'1'} | ${true} | ${true} + ${'1'} | ${false} | ${false} + ${''} | ${true} | ${false} + ${''} | ${false} | ${false} + ${null} | ${true} | ${false} + `( + 'returns true when groupId is a truthy string and groupMilestonesAvailable is true', + ({ groupId, groupMilestonesAvailable, groupMilestonesEnabled }) => { + expect(getters.groupMilestonesEnabled({ groupId, groupMilestonesAvailable })).toBe( + groupMilestonesEnabled, + ); + }, + ); + }); }); diff --git a/spec/frontend/milestones/stores/mutations_spec.js b/spec/frontend/milestones/stores/mutations_spec.js index 8f8ce3c87ad..0b69a9d572d 100644 --- a/spec/frontend/milestones/stores/mutations_spec.js +++ b/spec/frontend/milestones/stores/mutations_spec.js @@ -14,13 +14,19 @@ describe('Milestones combobox Vuex store mutations', () => { expect(state).toEqual({ projectId: null, groupId: null, - query: '', + groupMilestonesAvailable: false, + searchQuery: '', matches: { projectMilestones: { list: [], totalCount: 0, error: null, }, + groupMilestones: { + list: [], + totalCount: 0, + error: null, + }, }, selectedMilestones: [], requestCount: 0, @@ -37,6 +43,24 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); + describe(`${types.SET_GROUP_ID}`, () => { + it('updates the group ID', () => { + const newGroupId = '8'; + mutations[types.SET_GROUP_ID](state, newGroupId); + + expect(state.groupId).toBe(newGroupId); + }); + }); + + describe(`${types.SET_GROUP_MILESTONES_AVAILABLE}`, () => { + it('sets boolean indicating if group milestones are available', () => { + const groupMilestonesAvailable = true; + mutations[types.SET_GROUP_MILESTONES_AVAILABLE](state, groupMilestonesAvailable); + + expect(state.groupMilestonesAvailable).toBe(groupMilestonesAvailable); + }); + }); + describe(`${types.SET_SELECTED_MILESTONES}`, () => { it('sets the selected milestones', () => { const selectedMilestones = ['v1.2.3']; @@ -46,7 +70,21 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); - describe(`${types.ADD_SELECTED_MILESTONESs}`, () => { + describe(`${types.CLEAR_SELECTED_MILESTONES}`, () => { + it('clears the selected milestones', () => { + const selectedMilestones = ['v1.2.3']; + + // Set state.selectedMilestones + mutations[types.SET_SELECTED_MILESTONES](state, selectedMilestones); + + // Clear state.selectedMilestones + mutations[types.CLEAR_SELECTED_MILESTONES](state); + + expect(state.selectedMilestones).toEqual([]); + }); + }); + + describe(`${types.ADD_SELECTED_MILESTONES}`, () => { it('adds the selected milestones', () => { const selectedMilestone = 'v1.2.3'; mutations[types.ADD_SELECTED_MILESTONE](state, selectedMilestone); @@ -67,12 +105,12 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); - describe(`${types.SET_QUERY}`, () => { + describe(`${types.SET_SEARCH_QUERY}`, () => { it('updates the search query', () => { const newQuery = 'hello'; - mutations[types.SET_QUERY](state, newQuery); + mutations[types.SET_SEARCH_QUERY](state, newQuery); - expect(state.query).toBe(newQuery); + expect(state.searchQuery).toBe(newQuery); }); }); @@ -156,4 +194,57 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); }); + + describe(`${types.RECEIVE_GROUP_MILESTONES_SUCCESS}`, () => { + it('updates state.matches.groupMilestones based on the provided API response', () => { + const response = { + data: [ + { + title: 'group-0.1', + }, + { + title: 'group-0.2', + }, + ], + headers: { + 'x-total': 2, + }, + }; + + mutations[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response); + + expect(state.matches.groupMilestones).toEqual({ + list: [ + { + title: 'group-0.1', + }, + { + title: 'group-0.2', + }, + ], + error: null, + totalCount: 2, + }); + }); + + describe(`${types.RECEIVE_GROUP_MILESTONES_ERROR}`, () => { + it('updates state.matches.groupMilestones to an empty state with the error object', () => { + const error = new Error('Something went wrong!'); + + state.matches.groupMilestones = { + list: [{ title: 'group-0.1' }], + totalCount: 1, + error: null, + }; + + mutations[types.RECEIVE_GROUP_MILESTONES_ERROR](state, error); + + expect(state.matches.groupMilestones).toEqual({ + list: [], + totalCount: 0, + error, + }); + }); + }); + }); }); diff --git a/spec/frontend/monitoring/components/charts/column_spec.js b/spec/frontend/monitoring/components/charts/column_spec.js index 16e2080c000..fbcff33d692 100644 --- a/spec/frontend/monitoring/components/charts/column_spec.js +++ b/spec/frontend/monitoring/components/charts/column_spec.js @@ -30,6 +30,7 @@ describe('Column component', () => { }, metrics: [ { + label: 'Mock data', result: [ { metric: {}, @@ -96,7 +97,7 @@ describe('Column component', () => { describe('wrapped components', () => { describe('GitLab UI column chart', () => { it('receives data properties needed for proper chart render', () => { - expect(chartProps('data').values).toEqual(dataValues); + expect(chartProps('bars')).toEqual([{ name: 'Mock data', data: dataValues }]); }); it('passes the y axis name correctly', () => { diff --git a/spec/frontend/monitoring/components/charts/stacked_column_spec.js b/spec/frontend/monitoring/components/charts/stacked_column_spec.js index 24a2af87eb8..2032258730a 100644 --- a/spec/frontend/monitoring/components/charts/stacked_column_spec.js +++ b/spec/frontend/monitoring/components/charts/stacked_column_spec.js @@ -44,19 +44,19 @@ describe('Stacked column chart component', () => { }); it('data should match the graphData y value for each series', () => { - const data = findChart().props('data'); + const data = findChart().props('bars'); data.forEach((series, index) => { const { values } = stackedColumnMockedData.metrics[index].result[0]; - expect(series).toEqual(values.map(value => value[1])); + expect(series.data).toEqual(values.map(value => value[1])); }); }); - it('series names should be the same as the graphData metrics labels', () => { - const seriesNames = findChart().props('seriesNames'); + it('data should be the same length as the graphData metrics labels', () => { + const barDataProp = findChart().props('bars'); - expect(seriesNames).toHaveLength(stackedColumnMockedData.metrics.length); - seriesNames.forEach((name, index) => { + expect(barDataProp).toHaveLength(stackedColumnMockedData.metrics.length); + barDataProp.forEach(({ name }, index) => { expect(stackedColumnMockedData.metrics[index].label).toBe(name); }); }); diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 7f0ff534db3..8fcee80a2d8 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -226,7 +226,7 @@ describe('Time series component', () => { ]); expect( - shallowWrapperContainsSlotText(wrapper.find(GlLineChart), 'tooltipContent', value), + shallowWrapperContainsSlotText(wrapper.find(GlLineChart), 'tooltip-content', value), ).toBe(true); }); @@ -651,7 +651,7 @@ describe('Time series component', () => { return wrapper.vm.$nextTick(() => { expect( - shallowWrapperContainsSlotText(findChartComponent(), 'tooltipTitle', mockTitle), + shallowWrapperContainsSlotText(findChartComponent(), 'tooltip-title', mockTitle), ).toBe(true); }); }); @@ -671,7 +671,7 @@ describe('Time series component', () => { it('uses deployment title', () => { expect( - shallowWrapperContainsSlotText(findChartComponent(), 'tooltipTitle', 'Deployed'), + shallowWrapperContainsSlotText(findChartComponent(), 'tooltip-title', 'Deployed'), ).toBe(true); }); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index ee0e1fd3176..1808faf8f0e 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -106,7 +106,7 @@ describe('Dashboard Panel', () => { {}, { slots: { - topLeft: `<div class="top-left-content">OK</div>`, + 'top-left': `<div class="top-left-content">OK</div>`, }, }, ); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index b7a0ea46b61..27e479ba498 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -508,7 +508,7 @@ describe('Dashboard', () => { const mockKeyup = key => window.dispatchEvent(new KeyboardEvent('keyup', { key })); const MockPanel = { - template: `<div><slot name="topLeft"/></div>`, + template: `<div><slot name="top-left"/></div>`, }; beforeEach(() => { diff --git a/spec/frontend/monitoring/components/embeds/embed_group_spec.js b/spec/frontend/monitoring/components/embeds/embed_group_spec.js index b63995ec2d4..01089752933 100644 --- a/spec/frontend/monitoring/components/embeds/embed_group_spec.js +++ b/spec/frontend/monitoring/components/embeds/embed_group_spec.js @@ -73,7 +73,7 @@ describe('Embed Group', () => { metricsWithDataGetter.mockReturnValue([1]); mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); - expect(wrapper.find('.card-body').classes()).not.toContain('d-none'); + expect(wrapper.find('.gl-card-body').classes()).not.toContain('d-none'); }); it('collapses when clicked', done => { @@ -83,7 +83,7 @@ describe('Embed Group', () => { wrapper.find(GlButton).trigger('click'); wrapper.vm.$nextTick(() => { - expect(wrapper.find('.card-body').classes()).toContain('d-none'); + expect(wrapper.find('.gl-card-body').classes()).toContain('d-none'); done(); }); }); diff --git a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js index 788f3abf617..cc384aef231 100644 --- a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js +++ b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; describe('Custom variable component', () => { @@ -23,8 +23,8 @@ describe('Custom variable component', () => { }); }; - const findDropdown = () => wrapper.find(GlDeprecatedDropdown); - const findDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem); + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); it('renders dropdown element when all necessary props are passed', () => { createShallowWrapper(); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index 3e1e43d0c6a..b26eb00bfdc 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -13,11 +13,11 @@ const createDiscussionMock = (props = {}) => const createNoteMock = (props = {}) => Object.assign(JSON.parse(JSON.stringify(discussionMock.notes[0])), props); const createResolvableNote = () => - createNoteMock({ resolvable: true, current_user: { can_resolve: true } }); + createNoteMock({ resolvable: true, current_user: { can_resolve_discussion: true } }); const createUnresolvableNote = () => - createNoteMock({ resolvable: false, current_user: { can_resolve: false } }); + createNoteMock({ resolvable: false, current_user: { can_resolve_discussion: false } }); const createUnallowedNote = () => - createNoteMock({ resolvable: true, current_user: { can_resolve: false } }); + createNoteMock({ resolvable: true, current_user: { can_resolve_discussion: false } }); describe('DiscussionActions', () => { let wrapper; diff --git a/spec/frontend/notes/components/discussion_filter_note_spec.js b/spec/frontend/notes/components/discussion_filter_note_spec.js index 4701108d315..d35f8f7c28d 100644 --- a/spec/frontend/notes/components/discussion_filter_note_spec.js +++ b/spec/frontend/notes/components/discussion_filter_note_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlSprintf } from '@gitlab/ui'; import DiscussionFilterNote from '~/notes/components/discussion_filter_note.vue'; import eventHub from '~/notes/event_hub'; @@ -6,7 +7,11 @@ describe('DiscussionFilterNote component', () => { let wrapper; const createComponent = () => { - wrapper = shallowMount(DiscussionFilterNote); + wrapper = shallowMount(DiscussionFilterNote, { + stubs: { + GlSprintf, + }, + }); }; beforeEach(() => { @@ -19,21 +24,27 @@ describe('DiscussionFilterNote component', () => { }); it('timelineContent renders a string containing instruction for switching feed type', () => { - expect(wrapper.find({ ref: 'timelineContent' }).html()).toBe( - "<div>You're only seeing <b>other activity</b> in the feed. To add a comment, switch to one of the following options.</div>", + expect(wrapper.find('[data-testid="discussion-filter-timeline-content"]').html()).toBe( + '<div data-testid="discussion-filter-timeline-content">You\'re only seeing <b>other activity</b> in the feed. To add a comment, switch to one of the following options.</div>', ); }); it('emits `dropdownSelect` event with 0 parameter on clicking Show all activity button', () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - wrapper.find({ ref: 'showAllActivity' }).vm.$emit('click'); + wrapper + .findAll(GlButton) + .at(0) + .vm.$emit('click'); expect(eventHub.$emit).toHaveBeenCalledWith('dropdownSelect', 0); }); it('emits `dropdownSelect` event with 1 parameter on clicking Show comments only button', () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - wrapper.find({ ref: 'showComments' }).vm.$emit('click'); + wrapper + .findAll(GlButton) + .at(1) + .vm.$emit('click'); expect(eventHub.$emit).toHaveBeenCalledWith('dropdownSelect', 1); }); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index a79c3bbacb7..f01c6c6b84e 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { shallowMount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; import { TEST_HOST } from 'spec/test_constants'; import AxiosMockAdapter from 'axios-mock-adapter'; import createStore from '~/notes/stores'; @@ -14,9 +14,9 @@ describe('noteActions', () => { let actions; let axiosMock; - const shallowMountNoteActions = (propsData, computed) => { + const mountNoteActions = (propsData, computed) => { const localVue = createLocalVue(); - return shallowMount(localVue.extend(noteActions), { + return mount(localVue.extend(noteActions), { store, propsData, localVue, @@ -61,7 +61,7 @@ describe('noteActions', () => { beforeEach(() => { store.dispatch('setUserData', userDataMock); - wrapper = shallowMountNoteActions(props); + wrapper = mountNoteActions(props); }); it('should render noteable author badge', () => { @@ -178,7 +178,7 @@ describe('noteActions', () => { }; beforeEach(() => { - wrapper = shallowMountNoteActions(props, { + wrapper = mountNoteActions(props, { targetType: () => 'issue', }); store.state.noteableData = { @@ -205,7 +205,7 @@ describe('noteActions', () => { }; beforeEach(() => { - wrapper = shallowMountNoteActions(props, { + wrapper = mountNoteActions(props, { targetType: () => 'issue', }); }); @@ -221,7 +221,7 @@ describe('noteActions', () => { describe('user is not logged in', () => { beforeEach(() => { store.dispatch('setUserData', {}); - wrapper = shallowMountNoteActions({ + wrapper = mountNoteActions({ ...props, canDelete: false, canEdit: false, @@ -241,7 +241,7 @@ describe('noteActions', () => { describe('for showReply = true', () => { beforeEach(() => { - wrapper = shallowMountNoteActions({ + wrapper = mountNoteActions({ ...props, showReply: true, }); @@ -256,7 +256,7 @@ describe('noteActions', () => { describe('for showReply = false', () => { beforeEach(() => { - wrapper = shallowMountNoteActions({ + wrapper = mountNoteActions({ ...props, showReply: false, }); @@ -273,7 +273,7 @@ describe('noteActions', () => { beforeEach(() => { store.dispatch('setUserData', userDataMock); - wrapper = shallowMountNoteActions({ ...props, canResolve: true, isDraft: true }); + wrapper = mountNoteActions({ ...props, canResolve: true, isDraft: true }); }); it('should render the right resolve button title', () => { diff --git a/spec/frontend/notes/components/note_awards_list_spec.js b/spec/frontend/notes/components/note_awards_list_spec.js index dce5424f154..5ab183e5452 100644 --- a/spec/frontend/notes/components/note_awards_list_spec.js +++ b/spec/frontend/notes/components/note_awards_list_spec.js @@ -92,15 +92,14 @@ describe('note_awards_list component', () => { }).$mount(); }; - const findTooltip = () => - vm.$el.querySelector('[data-original-title]').getAttribute('data-original-title'); + const findTooltip = () => vm.$el.querySelector('[title]').getAttribute('title'); it('should only escape & and " characters', () => { awardsMock = [...new Array(1)].map(createAwardEmoji); mountComponent(); const escapedName = awardsMock[0].user.name.replace(/&/g, '&').replace(/"/g, '"'); - expect(vm.$el.querySelector('[data-original-title]').outerHTML).toContain(escapedName); + expect(vm.$el.querySelector('[title]').outerHTML).toContain(escapedName); }); it('should not escape special HTML characters twice when only 1 person awarded', () => { diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index a5b5204509e..cc434d6c952 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -272,6 +272,7 @@ describe('issue_note_form component', () => { wrapper = createComponentWrapper(); wrapper.setProps({ ...props, + isDraft: true, noteId: '', discussion: { ...discussionMock, for_commit: false }, }); @@ -292,6 +293,27 @@ describe('issue_note_form component', () => { expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(true); }); + it('hides resolve checkbox', async () => { + wrapper.setProps({ + isDraft: false, + discussion: { + ...discussionMock, + notes: [ + ...discussionMock.notes.map(n => ({ + ...n, + resolvable: true, + current_user: { ...n.current_user, can_resolve_discussion: false }, + })), + ], + for_commit: false, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(false); + }); + it('hides actions for commits', () => { wrapper.setProps({ discussion: { for_commit: true } }); diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js index 2bb08b60569..69aab0d051e 100644 --- a/spec/frontend/notes/components/note_header_spec.js +++ b/spec/frontend/notes/components/note_header_spec.js @@ -1,7 +1,9 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; import Vuex from 'vuex'; +import { GlSprintf } from '@gitlab/ui'; import NoteHeader from '~/notes/components/note_header.vue'; +import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -28,6 +30,9 @@ describe('NoteHeader component', () => { path: '/root', state: 'active', username: 'root', + status: { + availability: '', + }, }; const createComponent = props => { @@ -37,6 +42,7 @@ describe('NoteHeader component', () => { actions, }), propsData: { ...props }, + stubs: { GlSprintf }, }); }; @@ -78,7 +84,7 @@ describe('NoteHeader component', () => { expanded: true, }); - expect(findChevronIcon().classes()).toContain('fa-chevron-up'); + expect(findChevronIcon().props('name')).toBe('chevron-up'); }); it('has chevron-down icon if expanded prop is false', () => { @@ -87,7 +93,7 @@ describe('NoteHeader component', () => { expanded: false, }); - expect(findChevronIcon().classes()).toContain('fa-chevron-down'); + expect(findChevronIcon().props('name')).toBe('chevron-down'); }); }); @@ -97,6 +103,12 @@ describe('NoteHeader component', () => { expect(wrapper.find('.js-user-link').exists()).toBe(true); }); + it('renders busy status if author availability is set', () => { + createComponent({ author: { ...author, status: { availability: AVAILABILITY_STATUS.BUSY } } }); + + expect(wrapper.find('.js-user-link').text()).toContain('(Busy)'); + }); + it('renders deleted user text if author is not passed as a prop', () => { createComponent(); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index 4ff64abe4cc..638a4edecd6 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -7,7 +7,7 @@ export const notesDataMock = { newSessionPath: '/users/sign_in?redirect_to_referer=yes', notesPath: '/gitlab-org/gitlab-foss/noteable/issue/98/notes', quickActionsDocsPath: '/help/user/project/quick_actions', - registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane', + registerPath: '/users/sign_up?redirect_to_referer=yes', prerenderedNotesCount: 1, closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', @@ -202,6 +202,7 @@ export const discussionMock = { can_edit: true, can_award_emoji: true, can_resolve: true, + can_resolve_discussion: true, }, discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', emoji_awardable: true, @@ -249,6 +250,7 @@ export const discussionMock = { can_edit: true, can_award_emoji: true, can_resolve: true, + can_resolve_discussion: true, }, discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', emoji_awardable: true, @@ -296,6 +298,7 @@ export const discussionMock = { can_edit: true, can_award_emoji: true, can_resolve: true, + can_resolve_discussion: true, }, discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', emoji_awardable: true, diff --git a/spec/frontend/packages/details/components/package_title_spec.js b/spec/frontend/packages/details/components/package_title_spec.js index d0ed78418af..61c6e824ab7 100644 --- a/spec/frontend/packages/details/components/package_title_spec.js +++ b/spec/frontend/packages/details/components/package_title_spec.js @@ -1,5 +1,6 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import PackageTitle from '~/packages/details/components/package_title.vue'; import PackageTags from '~/packages/shared/components/package_tags.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -53,6 +54,7 @@ describe('PackageTitle', () => { const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]'); const packageRef = () => wrapper.find('[data-testid="package-ref"]'); const packageTags = () => wrapper.find(PackageTags); + const packageBadges = () => wrapper.findAll('[data-testid="tag-badge"]'); afterEach(() => { wrapper.destroy(); @@ -70,6 +72,14 @@ describe('PackageTitle', () => { expect(wrapper.element).toMatchSnapshot(); }); + + it('with tags on mobile', async () => { + jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false); + await createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } }); + await wrapper.vm.$nextTick(); + + expect(packageBadges()).toHaveLength(mockTags.length); + }); }); describe('package title', () => { diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap index ce3a58c856d..d27038e765f 100644 --- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -10,7 +10,7 @@ exports[`packages_list_app renders 1`] = ` activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo" class="gl-tabs" contentclass=",gl-tab-content" - navclass="gl-tabs-nav" + navclass=",gl-tabs-nav" nofade="true" nonavstyle="true" tag="div" diff --git a/spec/frontend/pages/labels/components/promote_label_modal_spec.js b/spec/frontend/pages/labels/components/promote_label_modal_spec.js index 1fa12cf1365..f969808d78b 100644 --- a/spec/frontend/pages/labels/components/promote_label_modal_spec.js +++ b/spec/frontend/pages/labels/components/promote_label_modal_spec.js @@ -32,10 +32,9 @@ describe('Promote label modal', () => { }); it('contains a label span with the color', () => { - const labelFromTitle = vm.$el.querySelector('.modal-header .label.color-label'); - - expect(labelFromTitle.style.backgroundColor).not.toBe(null); - expect(labelFromTitle.textContent).toContain(vm.labelTitle); + expect(vm.labelColor).not.toBe(null); + expect(vm.labelColor).toBe(labelMockData.labelColor); + expect(vm.labelTitle).toBe(labelMockData.labelTitle); }); }); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js index 5da998d9d2d..cfe54016410 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js @@ -1,109 +1,98 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import Cookies from 'js-cookie'; import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue'; -const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); const cookieKey = 'pipeline_schedules_callout_dismissed'; const docsUrl = 'help/ci/scheduled_pipelines'; -const imageUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg'; +const illustrationUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg'; describe('Pipeline Schedule Callout', () => { - let calloutComponent; + let wrapper; - beforeEach(() => { - setFixtures(` - <div id='pipeline-schedules-callout' data-docs-url=${docsUrl} data-image-url=${imageUrl}></div> - `); - }); - - describe('independent of cookies', () => { - beforeEach(() => { - calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); - }); - - it('the component can be initialized', () => { - expect(calloutComponent).toBeDefined(); + const createComponent = () => { + wrapper = shallowMount(PipelineSchedulesCallout, { + provide: { + docsUrl, + illustrationUrl, + }, }); + }; - it('correctly sets docsUrl', () => { - expect(calloutComponent.docsUrl).toContain(docsUrl); - }); - - it('correctly sets imageUrl', () => { - expect(calloutComponent.imageUrl).toContain(imageUrl); - }); - }); + const findInnerContentOfCallout = () => wrapper.find('[data-testid="innerContent"]'); + const findDismissCalloutBtn = () => wrapper.find(GlButton); describe(`when ${cookieKey} cookie is set`, () => { - beforeEach(() => { + beforeEach(async () => { Cookies.set(cookieKey, true); - calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); + createComponent(); + + await wrapper.vm.$nextTick(); }); - it('correctly sets calloutDismissed to true', () => { - expect(calloutComponent.calloutDismissed).toBe(true); + afterEach(() => { + wrapper.destroy(); }); it('does not render the callout', () => { - expect(calloutComponent.$el.childNodes.length).toBe(0); + expect(findInnerContentOfCallout().exists()).toBe(false); }); }); describe('when cookie is not set', () => { beforeEach(() => { Cookies.remove(cookieKey); - calloutComponent = new PipelineSchedulesCalloutComponent().$mount(); + createComponent(); }); - it('correctly sets calloutDismissed to false', () => { - expect(calloutComponent.calloutDismissed).toBe(false); + afterEach(() => { + wrapper.destroy(); }); it('renders the callout container', () => { - expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull(); - }); - - it('renders the callout img', () => { - expect(calloutComponent.$el.outerHTML).toContain('<img'); + expect(findInnerContentOfCallout().exists()).toBe(true); }); it('renders the callout title', () => { - expect(calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines'); + expect(wrapper.find('h4').text()).toBe('Scheduling Pipelines'); }); it('renders the callout text', () => { - expect(calloutComponent.$el.outerHTML).toContain('runs pipelines in the future'); + expect(wrapper.find('p').text()).toContain('runs pipelines in the future'); }); it('renders the documentation url', () => { - expect(calloutComponent.$el.outerHTML).toContain(docsUrl); + expect(wrapper.find('a').attributes('href')).toBe(docsUrl); }); - it('updates calloutDismissed when close button is clicked', done => { - calloutComponent.$el.querySelector('#dismiss-callout-btn').click(); + describe('methods', () => { + it('#dismissCallout sets calloutDismissed to true', async () => { + expect(wrapper.vm.calloutDismissed).toBe(false); + + findDismissCalloutBtn().vm.$emit('click'); + + await wrapper.vm.$nextTick(); - Vue.nextTick(() => { - expect(calloutComponent.calloutDismissed).toBe(true); - done(); + expect(findInnerContentOfCallout().exists()).toBe(false); }); - }); - it('#dismissCallout updates calloutDismissed', done => { - calloutComponent.dismissCallout(); + it('sets cookie on dismiss', () => { + const setCookiesSpy = jest.spyOn(Cookies, 'set'); + + findDismissCalloutBtn().vm.$emit('click'); - Vue.nextTick(() => { - expect(calloutComponent.calloutDismissed).toBe(true); - done(); + expect(setCookiesSpy).toHaveBeenCalledWith('pipeline_schedules_callout_dismissed', true, { + expires: 365, + }); }); }); - it('is hidden when close button is clicked', done => { - calloutComponent.$el.querySelector('#dismiss-callout-btn').click(); + it('is hidden when close button is clicked', async () => { + findDismissCalloutBtn().vm.$emit('click'); - Vue.nextTick(() => { - expect(calloutComponent.$el.childNodes.length).toBe(0); - done(); - }); + await wrapper.vm.$nextTick(); + + expect(findInnerContentOfCallout().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js index 0d9af0cb856..4b50342bf84 100644 --- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js +++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js @@ -14,18 +14,16 @@ describe('preserve_url_fragment', () => { loadFixtures('sessions/new.html'); }); - it('adds the url fragment to all login and sign up form actions', () => { + it('adds the url fragment to the login form actions', () => { preserveUrlFragment('#L65'); expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in#L65'); - expect($('#new_new_user').attr('action')).toBe('http://test.host/users#L65'); }); - it('does not add an empty url fragment to login and sign up form actions', () => { + it('does not add an empty url fragment to the login form actions', () => { preserveUrlFragment(); expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in'); - expect($('#new_new_user').attr('action')).toBe('http://test.host/users'); }); it('does not add an empty query parameter to OmniAuth login buttons', () => { diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js index ff51b1184cb..739b45e2193 100644 --- a/spec/frontend/performance_bar/components/detailed_metric_spec.js +++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; import DetailedMetric from '~/performance_bar/components/detailed_metric.vue'; @@ -14,6 +15,11 @@ describe('detailedMetric', () => { }); }; + const findAllTraceBlocks = () => wrapper.findAll('pre'); + const findTraceBlockAtIndex = index => findAllTraceBlocks().at(index); + const findExpandBacktraceBtns = () => wrapper.findAll('[data-testid="backtrace-expand-btn"]'); + const findExpandedBacktraceBtnAtIndex = index => findExpandBacktraceBtns().at(index); + afterEach(() => { wrapper.destroy(); }); @@ -37,7 +43,12 @@ describe('detailedMetric', () => { describe('when the current request has details', () => { const requestDetails = [ { duration: '100', feature: 'find_commit', request: 'abcdef', backtrace: ['hello', 'world'] }, - { duration: '23', feature: 'rebase_in_progress', request: '', backtrace: ['world', 'hello'] }, + { + duration: '23', + feature: 'rebase_in_progress', + request: '', + backtrace: ['other', 'example'], + }, ]; describe('with a default metric name', () => { @@ -82,7 +93,7 @@ describe('detailedMetric', () => { expect(request.text()).toContain(requestDetails[index].request); }); - expect(wrapper.find('.text-expander.js-toggle-button')).not.toBeNull(); + expect(wrapper.find('.js-toggle-button')).not.toBeNull(); wrapper.findAll('.performance-bar-modal td:nth-child(2)').wrappers.forEach(request => { expect(request.text()).toContain('world'); @@ -96,6 +107,30 @@ describe('detailedMetric', () => { it('displays request warnings', () => { expect(wrapper.find(RequestWarning).exists()).toBe(true); }); + + it('can open and close traces', async () => { + expect(findAllTraceBlocks()).toHaveLength(0); + + // Each block click on a new trace and assert that the correct + // count is open and that the content is what we expect to ensure + // we opened or closed the right one + const secondExpandButton = findExpandedBacktraceBtnAtIndex(1); + + findExpandedBacktraceBtnAtIndex(0).vm.$emit('click'); + await nextTick(); + expect(findAllTraceBlocks()).toHaveLength(1); + expect(findTraceBlockAtIndex(0).text()).toContain(requestDetails[0].backtrace[0]); + + secondExpandButton.vm.$emit('click'); + await nextTick(); + expect(findAllTraceBlocks()).toHaveLength(2); + expect(findTraceBlockAtIndex(1).text()).toContain(requestDetails[1].backtrace[0]); + + secondExpandButton.vm.$emit('click'); + await nextTick(); + expect(findAllTraceBlocks()).toHaveLength(1); + expect(findTraceBlockAtIndex(0).text()).toContain(requestDetails[0].backtrace[0]); + }); }); describe('when using a custom metric title', () => { @@ -140,7 +175,11 @@ describe('detailedMetric', () => { }); }); - it('renders only the number of calls', () => { + it('renders only the number of calls', async () => { + expect(trimText(wrapper.text())).toEqual('456 notification bullet'); + + findExpandedBacktraceBtnAtIndex(0).vm.$emit('click'); + await nextTick(); expect(trimText(wrapper.text())).toEqual('456 notification backtrace bullet'); }); }); diff --git a/spec/frontend/pipeline_editor/components/text_editor_spec.js b/spec/frontend/pipeline_editor/components/text_editor_spec.js new file mode 100644 index 00000000000..39d205839f4 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/text_editor_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import { mockCiYml } from '../mock_data'; + +import TextEditor from '~/pipeline_editor/components/text_editor.vue'; + +describe('~/pipeline_editor/components/text_editor.vue', () => { + let wrapper; + + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(TextEditor, { + propsData: { + value: mockCiYml, + ...props, + }, + }); + }; + + const findEditor = () => wrapper.find(EditorLite); + + it('contains an editor', () => { + createComponent(); + + expect(findEditor().exists()).toBe(true); + }); + + it('editor contains the value provided', () => { + expect(findEditor().props('value')).toBe(mockCiYml); + }); + + it('editor is readony and configured for .yml', () => { + expect(findEditor().props('editorOptions')).toEqual({ readOnly: true }); + expect(findEditor().props('fileName')).toBe('*.yml'); + }); + + it('bubbles up editor-ready event', () => { + findEditor().vm.$emit('editor-ready'); + + expect(wrapper.emitted('editor-ready')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js new file mode 100644 index 00000000000..90acdf3ec0b --- /dev/null +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -0,0 +1,42 @@ +import Api from '~/api'; +import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from '../mock_data'; + +import { resolvers } from '~/pipeline_editor/graphql/resolvers'; + +jest.mock('~/api', () => { + return { + getRawFile: jest.fn(), + }; +}); + +describe('~/pipeline_editor/graphql/resolvers', () => { + describe('Query', () => { + describe('blobContent', () => { + beforeEach(() => { + Api.getRawFile.mockResolvedValue({ + data: mockCiYml, + }); + }); + + afterEach(() => { + Api.getRawFile.mockReset(); + }); + + it('resolves lint data with type names', async () => { + const result = resolvers.Query.blobContent(null, { + projectPath: mockProjectPath, + path: mockCiConfigPath, + ref: mockDefaultBranch, + }); + + expect(Api.getRawFile).toHaveBeenCalledWith(mockProjectPath, mockCiConfigPath, { + ref: mockDefaultBranch, + }); + + // eslint-disable-next-line no-underscore-dangle + expect(result.__typename).toBe('BlobContent'); + await expect(result.rawData).resolves.toBe(mockCiYml); + }); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js new file mode 100644 index 00000000000..96fa6e5e004 --- /dev/null +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -0,0 +1,10 @@ +export const mockProjectPath = 'user1/project1'; +export const mockDefaultBranch = 'master'; + +export const mockCiConfigPath = '.gitlab-ci.yml'; +export const mockCiYml = ` +job1: + stage: test + script: + - echo 'test' +`; diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js new file mode 100644 index 00000000000..46523baadf3 --- /dev/null +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -0,0 +1,139 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; + +import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from './mock_data'; +import TextEditor from '~/pipeline_editor/components/text_editor.vue'; +import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; + +describe('~/pipeline_editor/pipeline_editor_app.vue', () => { + let wrapper; + + const createComponent = ( + { props = {}, data = {}, loading = false } = {}, + mountFn = shallowMount, + ) => { + wrapper = mountFn(PipelineEditorApp, { + propsData: { + projectPath: mockProjectPath, + defaultBranch: mockDefaultBranch, + ciConfigPath: mockCiConfigPath, + ...props, + }, + data() { + return data; + }, + stubs: { + GlTabs, + TextEditor, + }, + mocks: { + $apollo: { + queries: { + content: { + loading, + }, + }, + }, + }, + }); + }; + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findAlert = () => wrapper.find(GlAlert); + const findTabAt = i => wrapper.findAll(GlTab).at(i); + const findEditorLite = () => wrapper.find(EditorLite); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays content', () => { + createComponent({ data: { content: mockCiYml } }); + + expect(findLoadingIcon().exists()).toBe(false); + expect(findEditorLite().props('value')).toBe(mockCiYml); + }); + + it('displays a loading icon if the query is loading', () => { + createComponent({ loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + describe('tabs', () => { + it('displays tabs and their content', () => { + createComponent({ data: { content: mockCiYml } }); + + expect( + findTabAt(0) + .find(EditorLite) + .exists(), + ).toBe(true); + expect( + findTabAt(1) + .find(PipelineGraph) + .exists(), + ).toBe(true); + }); + + it('displays editor tab lazily, until editor is ready', async () => { + createComponent({ data: { content: mockCiYml } }); + + expect(findTabAt(0).attributes('lazy')).toBe('true'); + + findEditorLite().vm.$emit('editor-ready'); + await nextTick(); + + expect(findTabAt(0).attributes('lazy')).toBe(undefined); + }); + }); + + describe('when in error state', () => { + class MockError extends Error { + constructor(message, data) { + super(message); + if (data) { + this.networkError = { + response: { data }, + }; + } + } + } + + it('shows a generic error', () => { + const error = new MockError('An error message'); + createComponent({ data: { error } }); + + expect(findAlert().text()).toBe('CI file could not be loaded: An error message'); + }); + + it('shows a ref missing error state', () => { + const error = new MockError('Ref missing!', { + error: 'ref is missing, ref is empty', + }); + createComponent({ data: { error } }); + + expect(findAlert().text()).toMatch( + 'CI file could not be loaded: ref is missing, ref is empty', + ); + }); + + it('shows a file missing error state', async () => { + const error = new MockError('File missing!', { + message: 'file not found', + }); + + await wrapper.setData({ error }); + + expect(findAlert().text()).toMatch('CI file could not be loaded: file not found'); + }); + }); +}); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 040c0fbecc5..197f646a22e 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -1,5 +1,5 @@ import { mount, shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlDropdownItem, GlForm, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -35,6 +35,7 @@ describe('Pipeline New Form', () => { const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]'); const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const getExpectedPostParams = () => JSON.parse(mock.history.post[0].data); const createComponent = (term = '', props = {}, method = shallowMount) => { @@ -207,6 +208,25 @@ describe('Pipeline New Form', () => { window.gon = origGon; }); + describe('loading state', () => { + it('loading icon is shown when content is requested and hidden when received', async () => { + createComponent('', mockParams, mount); + + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { + [mockYmlKey]: { + value: mockYmlValue, + description: mockYmlDesc, + }, + }); + + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + describe('when yml defines a variable with description', () => { beforeEach(async () => { createComponent('', mockParams, mount); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 062c9759a65..5a17be1af23 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -159,13 +159,13 @@ describe('graph component', () => { describe('triggered by', () => { describe('on click', () => { - it('should emit `onClickTriggeredBy` when triggered by linked pipeline is clicked', () => { + it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', () => { const btnWrapper = findExpandPipelineBtn(); btnWrapper.trigger('click'); btnWrapper.vm.$nextTick(() => { - expect(wrapper.emitted().onClickTriggeredBy).toEqual([ + expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([ store.state.pipeline.triggered_by, ]); }); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index 8e65f0d4f71..67986ca7739 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -2,11 +2,10 @@ import { mount } from '@vue/test-utils'; import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; - import mockData from './linked_pipelines_mock_data'; +import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; const mockPipeline = mockData.triggered[0]; - const validTriggeredPipelineId = mockPipeline.project.id; const invalidTriggeredPipelineId = mockPipeline.project.id + 5; @@ -40,6 +39,7 @@ describe('Linked pipeline', () => { pipeline: mockPipeline, projectId: invalidTriggeredPipelineId, columnTitle: 'Downstream', + type: DOWNSTREAM, }; beforeEach(() => { @@ -104,11 +104,13 @@ describe('Linked pipeline', () => { pipeline: mockPipeline, projectId: validTriggeredPipelineId, columnTitle: 'Downstream', + type: DOWNSTREAM, }; const upstreamProps = { ...downstreamProps, columnTitle: 'Upstream', + type: UPSTREAM, }; it('parent/child label container should exist', () => { @@ -182,6 +184,7 @@ describe('Linked pipeline', () => { pipeline: { ...mockPipeline, isLoading: true }, projectId: invalidTriggeredPipelineId, columnTitle: 'Downstream', + type: DOWNSTREAM, }; beforeEach(() => { @@ -198,6 +201,7 @@ describe('Linked pipeline', () => { pipeline: mockPipeline, projectId: validTriggeredPipelineId, columnTitle: 'Downstream', + type: DOWNSTREAM, }; beforeEach(() => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 82eaa553d0c..e6ae3154d1d 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; +import { UPSTREAM } from '~/pipelines/components/graph/constants'; import mockData from './linked_pipelines_mock_data'; describe('Linked Pipelines Column', () => { @@ -9,6 +10,7 @@ describe('Linked Pipelines Column', () => { linkedPipelines: mockData.triggered, graphPosition: 'right', projectId: 19, + type: UPSTREAM, }; let wrapper; diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index 2e10b0f068c..03e385e3cc8 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -1,19 +1,19 @@ import { shallowMount } from '@vue/test-utils'; import { GlModal, GlLoadingIcon } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; import { mockCancelledPipelineHeader, mockFailedPipelineHeader, mockRunningPipelineHeader, mockSuccessfulPipelineHeader, } from './mock_data'; -import axios from '~/lib/utils/axios_utils'; import HeaderComponent from '~/pipelines/components/header_component.vue'; +import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql'; +import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; +import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; describe('Pipeline details header', () => { let wrapper; let glModalDirective; - let mockAxios; const findDeleteModal = () => wrapper.find(GlModal); const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]'); @@ -25,9 +25,7 @@ describe('Pipeline details header', () => { pipelineId: 14, pipelineIid: 1, paths: { - retry: '/retry', - cancel: '/cancel', - delete: '/delete', + pipelinesPath: '/namespace/my-project/-/pipelines', fullProject: '/namespace/my-project', }, }; @@ -43,6 +41,7 @@ describe('Pipeline details header', () => { startPolling: jest.fn(), }, }, + mutate: jest.fn(), }; return shallowMount(HeaderComponent, { @@ -65,16 +64,9 @@ describe('Pipeline details header', () => { }); }; - beforeEach(() => { - mockAxios = new MockAdapter(axios); - mockAxios.onGet('*').replyOnce(200); - }); - afterEach(() => { wrapper.destroy(); wrapper = null; - - mockAxios.restore(); }); describe('initial loading', () => { @@ -105,19 +97,37 @@ describe('Pipeline details header', () => { ); }); + describe('polling', () => { + it('is stopped when pipeline is finished', async () => { + wrapper = createComponent({ ...mockRunningPipelineHeader }); + + await wrapper.setData({ + pipeline: { ...mockCancelledPipelineHeader }, + }); + + expect(wrapper.vm.$apollo.queries.pipeline.stopPolling).toHaveBeenCalled(); + }); + + it('is not stopped when pipeline is not finished', () => { + wrapper = createComponent(); + + expect(wrapper.vm.$apollo.queries.pipeline.stopPolling).not.toHaveBeenCalled(); + }); + }); + describe('actions', () => { describe('Retry action', () => { beforeEach(() => { wrapper = createComponent(mockCancelledPipelineHeader); }); - it('should call axios with the right path when retry button is clicked', async () => { - jest.spyOn(axios, 'post'); + it('should call retryPipeline Mutation with pipeline id', () => { findRetryButton().vm.$emit('click'); - await wrapper.vm.$nextTick(); - - expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.retry); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: retryPipelineMutation, + variables: { id: mockCancelledPipelineHeader.id }, + }); }); }); @@ -126,13 +136,13 @@ describe('Pipeline details header', () => { wrapper = createComponent(mockRunningPipelineHeader); }); - it('should call axios with the right path when cancel button is clicked', async () => { - jest.spyOn(axios, 'post'); + it('should call cancelPipeline Mutation with pipeline id', () => { findCancelButton().vm.$emit('click'); - await wrapper.vm.$nextTick(); - - expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.cancel); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: cancelPipelineMutation, + variables: { id: mockRunningPipelineHeader.id }, + }); }); }); @@ -141,24 +151,21 @@ describe('Pipeline details header', () => { wrapper = createComponent(mockFailedPipelineHeader); }); - it('displays delete modal when clicking on delete and does not call the delete action', async () => { - jest.spyOn(axios, 'delete'); + it('displays delete modal when clicking on delete and does not call the delete action', () => { findDeleteButton().vm.$emit('click'); - await wrapper.vm.$nextTick(); - expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID); expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID); - expect(axios.delete).not.toHaveBeenCalled(); + expect(wrapper.vm.$apollo.mutate).not.toHaveBeenCalled(); }); - it('should call delete path when modal is submitted', async () => { - jest.spyOn(axios, 'delete'); + it('should call deletePipeline Mutation with pipeline id when modal is submitted', () => { findDeleteModal().vm.$emit('ok'); - await wrapper.vm.$nextTick(); - - expect(axios.delete).toHaveBeenCalledWith(defaultProvideOptions.paths.delete); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: deletePipelineMutation, + variables: { id: mockFailedPipelineHeader.id }, + }); }); }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js index b50932deec6..4f55fdd6b28 100644 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -91,3 +91,18 @@ export const pipelineData = { [jobId4]: {}, }, }; + +export const singleStageData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + id: jobId1, + }, + ], + }, + ], +}; diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js index 30e192e5726..7c8ebc27974 100644 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { pipelineData } from './mock_data'; +import { pipelineData, singleStageData } from './mock_data'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; @@ -18,6 +18,8 @@ describe('pipeline graph component', () => { }; const findAllStagePills = () => wrapper.findAll(StagePill); + const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]'); + const findStageBackgroundElementAt = index => findAllStageBackgroundElements().at(index); const findAllJobPills = () => wrapper.findAll(JobPill); afterEach(() => { @@ -31,7 +33,9 @@ describe('pipeline graph component', () => { }); it('renders an empty section', () => { - expect(wrapper.text()).toContain('No content to show'); + expect(wrapper.text()).toContain( + 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', + ); expect(findAllStagePills()).toHaveLength(0); expect(findAllJobPills()).toHaveLength(0); }); @@ -41,12 +45,43 @@ describe('pipeline graph component', () => { beforeEach(() => { wrapper = createComponent(); }); + it('renders the right number of stage pills', () => { const expectedStagesLength = pipelineData.stages.length; expect(findAllStagePills()).toHaveLength(expectedStagesLength); }); + it.each` + cssClass | expectedState + ${'gl-rounded-bottom-left-6'} | ${true} + ${'gl-rounded-top-left-6'} | ${true} + ${'gl-rounded-top-right-6'} | ${false} + ${'gl-rounded-bottom-right-6'} | ${false} + `( + 'rounds corner: $class should be $expectedState on the first element', + ({ cssClass, expectedState }) => { + const classes = findStageBackgroundElementAt(0).classes(); + + expect(classes.includes(cssClass)).toBe(expectedState); + }, + ); + + it.each` + cssClass | expectedState + ${'gl-rounded-bottom-left-6'} | ${false} + ${'gl-rounded-top-left-6'} | ${false} + ${'gl-rounded-top-right-6'} | ${true} + ${'gl-rounded-bottom-right-6'} | ${true} + `( + 'rounds corner: $class should be $expectedState on the last element', + ({ cssClass, expectedState }) => { + const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes(); + + expect(classes.includes(cssClass)).toBe(expectedState); + }, + ); + it('renders the right number of job pills', () => { // We count the number of jobs in the mock data const expectedJobsLength = pipelineData.stages.reduce((acc, val) => { @@ -56,4 +91,25 @@ describe('pipeline graph component', () => { expect(findAllJobPills()).toHaveLength(expectedJobsLength); }); }); + + describe('with only one stage', () => { + beforeEach(() => { + wrapper = createComponent({ pipelineData: singleStageData }); + }); + + it.each` + cssClass | expectedState + ${'gl-rounded-bottom-left-6'} | ${true} + ${'gl-rounded-top-left-6'} | ${true} + ${'gl-rounded-top-right-6'} | ${true} + ${'gl-rounded-bottom-right-6'} | ${true} + `( + 'rounds corner: $class should be $expectedState on the only element', + ({ cssClass, expectedState }) => { + const classes = findStageBackgroundElementAt(0).classes(); + + expect(classes.includes(cssClass)).toBe(expectedState); + }, + ); + }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 1298a2a1524..a272803f9b6 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -74,7 +74,6 @@ describe('Pipelines', () => { const createComponent = (props = defaultProps, methods) => { wrapper = mount(PipelinesComponent, { - provide: { glFeatures: { filterPipelinesSearch: true } }, propsData: { store: new Store(), projectId: '21', @@ -373,7 +372,6 @@ describe('Pipelines', () => { }); it('should render table', () => { - expect(wrapper.find('.table-holder').exists()).toBe(true); expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength( pipelines.pipelines.length + 1, ); diff --git a/spec/frontend/pipelines/test_reports/test_case_details_spec.js b/spec/frontend/pipelines/test_reports/test_case_details_spec.js new file mode 100644 index 00000000000..9e66012818e --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_case_details_spec.js @@ -0,0 +1,74 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; +import CodeBlock from '~/vue_shared/components/code_block.vue'; + +const localVue = createLocalVue(); + +describe('Test case details', () => { + let wrapper; + const defaultTestCase = { + classname: 'spec.test_spec', + name: 'Test#something cool', + formattedTime: '10.04ms', + system_output: 'Line 42 is broken', + }; + + const findModal = () => wrapper.find(GlModal); + const findName = () => wrapper.find('[data-testid="test-case-name"]'); + const findDuration = () => wrapper.find('[data-testid="test-case-duration"]'); + const findSystemOutput = () => wrapper.find('[data-testid="test-case-trace"]'); + + const createComponent = (testCase = {}) => { + wrapper = shallowMount(TestCaseDetails, { + localVue, + propsData: { + modalId: 'my-modal', + testCase: { + ...defaultTestCase, + ...testCase, + }, + }, + stubs: { CodeBlock, GlModal }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('required details', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the test case classname as modal title', () => { + expect(findModal().attributes('title')).toBe(defaultTestCase.classname); + }); + + it('renders the test case name', () => { + expect(findName().text()).toBe(defaultTestCase.name); + }); + + it('renders the test case duration', () => { + expect(findDuration().text()).toBe(defaultTestCase.formattedTime); + }); + }); + + describe('when test case has system output', () => { + it('renders the test case system output', () => { + createComponent(); + + expect(findSystemOutput().text()).toContain(defaultTestCase.system_output); + }); + }); + + describe('when test case does not have system output', () => { + it('does not render the test case system output', () => { + createComponent({ system_output: null }); + + expect(findSystemOutput().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index 838e0606375..284099b000b 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,7 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { getJSONFixture } from 'helpers/fixtures'; -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlFriendlyWrap } from '@gitlab/ui'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { TestStatus } from '~/pipelines/constants'; @@ -40,6 +40,7 @@ describe('Test reports suite table', () => { wrapper = shallowMount(SuiteTable, { store, localVue, + stubs: { GlFriendlyWrap }, }); }; diff --git a/spec/frontend/popovers/components/popovers_spec.js b/spec/frontend/popovers/components/popovers_spec.js new file mode 100644 index 00000000000..63e0b3d9c49 --- /dev/null +++ b/spec/frontend/popovers/components/popovers_spec.js @@ -0,0 +1,129 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlPopover } from '@gitlab/ui'; +import { useMockMutationObserver } from 'helpers/mock_dom_observer'; +import Popovers from '~/popovers/components/popovers.vue'; + +describe('popovers/components/popovers.vue', () => { + const { trigger: triggerMutate, observersCount } = useMockMutationObserver(); + let wrapper; + + const buildWrapper = (...targets) => { + wrapper = shallowMount(Popovers); + wrapper.vm.addPopovers(targets); + return wrapper.vm.$nextTick(); + }; + + const createPopoverTarget = (options = {}) => { + const target = document.createElement('button'); + const dataset = { + title: 'default title', + content: 'some content', + ...options, + }; + + Object.entries(dataset).forEach(([key, value]) => { + target.dataset[key] = value; + }); + + document.body.appendChild(target); + + return target; + }; + + const allPopovers = () => wrapper.findAll(GlPopover); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('addPopovers', () => { + it('attaches popovers to the targets specified', async () => { + const target = createPopoverTarget(); + await buildWrapper(target); + expect(wrapper.find(GlPopover).props('target')).toBe(target); + }); + + it('does not attach a popover twice to the same element', async () => { + const target = createPopoverTarget(); + buildWrapper(target); + wrapper.vm.addPopovers([target]); + + await wrapper.vm.$nextTick(); + + expect(wrapper.findAll(GlPopover)).toHaveLength(1); + }); + + it('supports HTML content', async () => { + const content = 'content with <b>HTML</b>'; + await buildWrapper( + createPopoverTarget({ + content, + html: true, + }), + ); + const html = wrapper.find(GlPopover).html(); + + expect(html).toContain(content); + }); + + it.each` + option | value + ${'placement'} | ${'bottom'} + ${'triggers'} | ${'manual'} + `('sets $option to $value when data-$option is set in target', async ({ option, value }) => { + await buildWrapper(createPopoverTarget({ [option]: value })); + + expect(wrapper.find(GlPopover).props(option)).toBe(value); + }); + }); + + describe('dispose', () => { + it('removes all popovers when elements is nil', async () => { + await buildWrapper(createPopoverTarget(), createPopoverTarget()); + + wrapper.vm.dispose(); + await wrapper.vm.$nextTick(); + + expect(allPopovers()).toHaveLength(0); + }); + + it('removes the popovers that target the elements specified', async () => { + const target = createPopoverTarget(); + + await buildWrapper(target, createPopoverTarget()); + + wrapper.vm.dispose(target); + await wrapper.vm.$nextTick(); + + expect(allPopovers()).toHaveLength(1); + }); + }); + + describe('observe', () => { + it('removes popover when target is removed from the document', async () => { + const target = createPopoverTarget(); + await buildWrapper(target); + + wrapper.vm.addPopovers([target, createPopoverTarget()]); + await wrapper.vm.$nextTick(); + + triggerMutate(document.body, { + entry: { removedNodes: [target] }, + options: { childList: true }, + }); + await wrapper.vm.$nextTick(); + + expect(allPopovers()).toHaveLength(1); + }); + }); + + it('disconnects mutation observer on beforeDestroy', async () => { + await buildWrapper(createPopoverTarget()); + + expect(observersCount()).toBe(1); + + wrapper.destroy(); + expect(observersCount()).toBe(0); + }); +}); diff --git a/spec/frontend/popovers/index_spec.js b/spec/frontend/popovers/index_spec.js new file mode 100644 index 00000000000..ea3b78332d7 --- /dev/null +++ b/spec/frontend/popovers/index_spec.js @@ -0,0 +1,104 @@ +import { initPopovers, dispose, destroy } from '~/popovers'; + +describe('popovers/index.js', () => { + let popoversApp; + + const createPopoverTarget = (trigger = 'hover') => { + const target = document.createElement('button'); + const dataset = { + title: 'default title', + content: 'some content', + toggle: 'popover', + trigger, + }; + + Object.entries(dataset).forEach(([key, value]) => { + target.dataset[key] = value; + }); + + document.body.appendChild(target); + + return target; + }; + + const buildPopoversApp = () => { + popoversApp = initPopovers('[data-toggle="popover"]'); + }; + + const triggerEvent = (target, eventName = 'mouseenter') => { + const event = new Event(eventName); + + target.dispatchEvent(event); + }; + + afterEach(() => { + document.body.innerHTML = ''; + destroy(); + }); + + describe('initPopover', () => { + it('attaches a GlPopover for the elements specified in the selector', async () => { + const target = createPopoverTarget(); + + buildPopoversApp(); + + triggerEvent(target); + + await popoversApp.$nextTick(); + const html = document.querySelector('.gl-popover').innerHTML; + + expect(document.querySelector('.gl-popover')).not.toBe(null); + expect(html).toContain('default title'); + expect(html).toContain('some content'); + }); + + it('supports triggering a popover via custom events', async () => { + const trigger = 'click'; + const target = createPopoverTarget(trigger); + + buildPopoversApp(); + triggerEvent(target, trigger); + + await popoversApp.$nextTick(); + + expect(document.querySelector('.gl-popover')).not.toBe(null); + expect(document.querySelector('.gl-popover').innerHTML).toContain('default title'); + }); + + it('inits popovers on targets added after content load', async () => { + buildPopoversApp(); + + expect(document.querySelector('.gl-popover')).toBe(null); + + const trigger = 'click'; + const target = createPopoverTarget(trigger); + triggerEvent(target, trigger); + await popoversApp.$nextTick(); + + expect(document.querySelector('.gl-popover')).not.toBe(null); + }); + }); + + describe('dispose', () => { + it('removes popovers that target the elements specified', async () => { + const fakeTarget = createPopoverTarget(); + const target = createPopoverTarget(); + buildPopoversApp(); + triggerEvent(target); + triggerEvent(createPopoverTarget()); + await popoversApp.$nextTick(); + + expect(document.querySelectorAll('.gl-popover')).toHaveLength(2); + + dispose([fakeTarget]); + await popoversApp.$nextTick(); + + expect(document.querySelectorAll('.gl-popover')).toHaveLength(2); + + dispose([target]); + await popoversApp.$nextTick(); + + expect(document.querySelectorAll('.gl-popover')).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index be39a7f4d80..45e5e0f885f 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -1,173 +1,135 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; -import mountComponent from 'helpers/vue_mount_component_helper'; import axios from '~/lib/utils/axios_utils'; -import updateUsername from '~/profile/account/components/update_username.vue'; +import UpdateUsername from '~/profile/account/components/update_username.vue'; describe('UpdateUsername component', () => { const rootUrl = TEST_HOST; const actionUrl = `${TEST_HOST}/update/username`; - const username = 'hasnoname'; - const newUsername = 'new_username'; - let Component; - let vm; + const defaultProps = { + actionUrl, + rootUrl, + initialUsername: 'hasnoname', + }; + let wrapper; let axiosMock; + const createComponent = (props = {}) => { + wrapper = shallowMount(UpdateUsername, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlModal, + }, + }); + }; + beforeEach(() => { axiosMock = new MockAdapter(axios); - Component = Vue.extend(updateUsername); - vm = mountComponent(Component, { - actionUrl, - rootUrl, - initialUsername: username, - }); + createComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); axiosMock.restore(); }); const findElements = () => { - const modalSelector = `#${vm.$options.modalId}`; + const modal = wrapper.find(GlModal); return { - input: vm.$el.querySelector(`#${vm.$options.inputId}`), - openModalBtn: vm.$el.querySelector(`[data-target="${modalSelector}"]`), - modal: vm.$el.querySelector(modalSelector), - modalBody: vm.$el.querySelector(`${modalSelector} .modal-body`), - modalHeader: vm.$el.querySelector(`${modalSelector} .modal-title`), - confirmModalBtn: vm.$el.querySelector(`${modalSelector} .btn-warning`), + modal, + input: wrapper.find(`#${wrapper.vm.$options.inputId}`), + openModalBtn: wrapper.find('[data-testid="username-change-confirmation-modal"]'), + modalBody: modal.find('.modal-body'), + modalHeader: modal.find('.modal-title'), + confirmModalBtn: wrapper.find('.btn-warning'), }; }; - it('has a disabled button if the username was not changed', done => { - const { input, openModalBtn } = findElements(); - input.dispatchEvent(new Event('input')); - - Vue.nextTick() - .then(() => { - expect(vm.username).toBe(username); - expect(vm.newUsername).toBe(username); - expect(openModalBtn).toBeDisabled(); - }) - .then(done) - .catch(done.fail); + it('has a disabled button if the username was not changed', async () => { + const { openModalBtn } = findElements(); + + await wrapper.vm.$nextTick(); + + expect(openModalBtn.props('disabled')).toBe(true); }); - it('has an enabled button which if the username was changed', done => { + it('has an enabled button which if the username was changed', async () => { const { input, openModalBtn } = findElements(); - input.value = newUsername; - input.dispatchEvent(new Event('input')); - - Vue.nextTick() - .then(() => { - expect(vm.username).toBe(username); - expect(vm.newUsername).toBe(newUsername); - expect(openModalBtn).not.toBeDisabled(); - }) - .then(done) - .catch(done.fail); - }); - it('confirmation modal contains proper header and body', done => { - const { modalBody, modalHeader } = findElements(); + input.element.value = 'newUsername'; + input.trigger('input'); - vm.newUsername = newUsername; + await wrapper.vm.$nextTick(); - Vue.nextTick() - .then(() => { - expect(modalHeader.textContent).toContain('Change username?'); - expect(modalBody.textContent).toContain( - `You are going to change the username ${username} to ${newUsername}`, - ); - }) - .then(done) - .catch(done.fail); + expect(openModalBtn.props('disabled')).toBe(false); }); - it('confirmation modal should escape usernames properly', done => { - const { modalBody } = findElements(); + describe('changing username', () => { + const newUsername = 'new_username'; - vm.username = '<i>Italic</i>'; - vm.newUsername = vm.username; + beforeEach(async () => { + createComponent(); + wrapper.setData({ newUsername }); - Vue.nextTick() - .then(() => { - expect(modalBody.innerHTML).toContain('<i>Italic</i>'); - expect(modalBody.innerHTML).not.toContain(vm.username); - }) - .then(done) - .catch(done.fail); - }); + await wrapper.vm.$nextTick(); + }); - it('executes API call on confirmation button click', done => { - const { confirmModalBtn } = findElements(); + it('confirmation modal contains proper header and body', async () => { + const { modal } = findElements(); - axiosMock.onPut(actionUrl).replyOnce(() => [200, { message: 'Username changed' }]); - jest.spyOn(axios, 'put'); + expect(modal.attributes('title')).toBe('Change username?'); + expect(modal.text()).toContain( + `You are going to change the username ${defaultProps.initialUsername} to ${newUsername}`, + ); + }); - vm.newUsername = newUsername; + it('executes API call on confirmation button click', async () => { + axiosMock.onPut(actionUrl).replyOnce(() => [200, { message: 'Username changed' }]); + jest.spyOn(axios, 'put'); - Vue.nextTick() - .then(() => { - confirmModalBtn.click(); + await wrapper.vm.onConfirm(); + await wrapper.vm.$nextTick(); - expect(axios.put).toHaveBeenCalledWith(actionUrl, { user: { username: newUsername } }); - }) - .then(done) - .catch(done.fail); - }); + expect(axios.put).toHaveBeenCalledWith(actionUrl, { user: { username: newUsername } }); + }); - it('sets the username after a successful update', done => { - const { input, openModalBtn } = findElements(); + it('sets the username after a successful update', async () => { + const { input, openModalBtn } = findElements(); - axiosMock.onPut(actionUrl).replyOnce(() => { - expect(input).toBeDisabled(); - expect(openModalBtn).toBeDisabled(); + axiosMock.onPut(actionUrl).replyOnce(() => { + expect(input.attributes('disabled')).toBe('disabled'); + expect(openModalBtn.props('disabled')).toBe(true); - return [200, { message: 'Username changed' }]; + return [200, { message: 'Username changed' }]; + }); + + await wrapper.vm.onConfirm(); + await wrapper.vm.$nextTick(); + + expect(input.attributes('disabled')).toBe(undefined); + expect(openModalBtn.props('disabled')).toBe(true); }); - vm.newUsername = newUsername; - - vm.onConfirm() - .then(() => { - expect(vm.username).toBe(newUsername); - expect(vm.newUsername).toBe(newUsername); - expect(input).not.toBeDisabled(); - expect(input.value).toBe(newUsername); - expect(openModalBtn).toBeDisabled(); - }) - .then(done) - .catch(done.fail); - }); + it('does not set the username after a erroneous update', async () => { + const { input, openModalBtn } = findElements(); - it('does not set the username after a erroneous update', done => { - const { input, openModalBtn } = findElements(); + axiosMock.onPut(actionUrl).replyOnce(() => { + expect(input.attributes('disabled')).toBe('disabled'); + expect(openModalBtn.props('disabled')).toBe(true); - axiosMock.onPut(actionUrl).replyOnce(() => { - expect(input).toBeDisabled(); - expect(openModalBtn).toBeDisabled(); + return [400, { message: 'Invalid username' }]; + }); - return [400, { message: 'Invalid username' }]; + await expect(wrapper.vm.onConfirm()).rejects.toThrow(); + expect(input.attributes('disabled')).toBe(undefined); + expect(openModalBtn.props('disabled')).toBe(false); }); - - const invalidUsername = 'anything.git'; - vm.newUsername = invalidUsername; - - vm.onConfirm() - .then(() => done.fail('Expected onConfirm to throw!')) - .catch(() => { - expect(vm.username).toBe(username); - expect(vm.newUsername).toBe(invalidUsername); - expect(input).not.toBeDisabled(); - expect(input.value).toBe(invalidUsername); - expect(openModalBtn).not.toBeDisabled(); - }) - .then(done) - .catch(done.fail); }); }); diff --git a/spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap new file mode 100644 index 00000000000..2fd1fd6a04e --- /dev/null +++ b/spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IntegrationView component should render IntegrationView properly 1`] = ` +<div + name="sourcegraph" +> + <label + class="label-bold" + > + + Foo + + </label> + + <gl-link-stub + class="has-tooltip" + href="http://foo.com/help" + title="More information" + > + <gl-icon-stub + class="vertical-align-middle" + name="question-o" + size="16" + /> + </gl-link-stub> + + <div + class="form-group form-check" + data-testid="profile-preferences-integration-form-group" + > + <input + data-testid="profile-preferences-integration-hidden-field" + name="user[foo_enabled]" + type="hidden" + value="0" + /> + + <input + class="form-check-input" + data-testid="profile-preferences-integration-checkbox" + id="user_foo_enabled" + name="user[foo_enabled]" + type="checkbox" + value="1" + /> + + <label + class="form-check-label" + for="user_foo_enabled" + > + + Enable foo + + </label> + + <gl-form-text-stub + tag="div" + textvariant="muted" + > + <integration-help-text-stub + message="Click %{linkStart}Foo%{linkEnd}!" + messageurl="http://foo.com" + /> + </gl-form-text-stub> + </div> +</div> +`; diff --git a/spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap new file mode 100644 index 00000000000..4df92cf86a5 --- /dev/null +++ b/spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProfilePreferences component should render ProfilePreferences properly 1`] = ` +<div + class="row gl-mt-3 js-preferences-form" +> + <div + class="col-sm-12" + > + <hr + data-testid="profile-preferences-integrations-rule" + /> + </div> + + <div + class="col-lg-4 profile-settings-sidebar" + > + <h4 + class="gl-mt-0" + data-testid="profile-preferences-integrations-heading" + > + + Integrations + + </h4> + + <p> + + Customize integrations with third party services. + + </p> + </div> + + <div + class="col-lg-8" + > + <integration-view-stub + config="[object Object]" + helplink="http://foo.com/help" + message="Click %{linkStart}Foo%{linkEnd}!" + messageurl="http://foo.com" + /> + <integration-view-stub + config="[object Object]" + helplink="http://bar.com/help" + message="Click %{linkStart}Bar%{linkEnd}!" + messageurl="http://bar.com" + /> + </div> +</div> +`; diff --git a/spec/frontend/profile/preferences/components/integration_view_spec.js b/spec/frontend/profile/preferences/components/integration_view_spec.js new file mode 100644 index 00000000000..5d55a089119 --- /dev/null +++ b/spec/frontend/profile/preferences/components/integration_view_spec.js @@ -0,0 +1,124 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlFormText } from '@gitlab/ui'; +import IntegrationView from '~/profile/preferences/components/integration_view.vue'; +import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { integrationViews, userFields } from '../mock_data'; + +const viewProps = convertObjectPropsToCamelCase(integrationViews[0]); + +describe('IntegrationView component', () => { + let wrapper; + const defaultProps = { + config: { + title: 'Foo', + label: 'Enable foo', + formName: 'foo_enabled', + }, + ...viewProps, + }; + + function createComponent(options = {}) { + const { props = {}, provide = {} } = options; + return shallowMount(IntegrationView, { + provide: { + userFields, + ...provide, + }, + propsData: { + ...defaultProps, + ...props, + }, + }); + } + + function findCheckbox() { + return wrapper.find('[data-testid="profile-preferences-integration-checkbox"]'); + } + function findFormGroup() { + return wrapper.find('[data-testid="profile-preferences-integration-form-group"]'); + } + function findHiddenField() { + return wrapper.find('[data-testid="profile-preferences-integration-hidden-field"]'); + } + function findFormGroupLabel() { + return wrapper.find('[data-testid="profile-preferences-integration-form-group"] label'); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should render the title correctly', () => { + wrapper = createComponent(); + + expect(wrapper.find('label.label-bold').text()).toBe('Foo'); + }); + + it('should render the form correctly', () => { + wrapper = createComponent(); + + expect(findFormGroup().exists()).toBe(true); + expect(findHiddenField().exists()).toBe(true); + expect(findCheckbox().exists()).toBe(true); + expect(findCheckbox().attributes('id')).toBe('user_foo_enabled'); + expect(findCheckbox().attributes('name')).toBe('user[foo_enabled]'); + }); + + it('should have the checkbox value to be set to 1', () => { + wrapper = createComponent(); + + expect(findCheckbox().attributes('value')).toBe('1'); + }); + + it('should have the hidden value to be set to 0', () => { + wrapper = createComponent(); + + expect(findHiddenField().attributes('value')).toBe('0'); + }); + + it('should set the checkbox value to be true', () => { + wrapper = createComponent(); + + expect(findCheckbox().element.checked).toBe(true); + }); + + it('should set the checkbox value to be false when false is provided', () => { + wrapper = createComponent({ + provide: { + userFields: { + foo_enabled: false, + }, + }, + }); + + expect(findCheckbox().element.checked).toBe(false); + }); + + it('should set the checkbox value to be false when not provided', () => { + wrapper = createComponent({ provide: { userFields: {} } }); + + expect(findCheckbox().element.checked).toBe(false); + }); + + it('should render the help text', () => { + wrapper = createComponent(); + + expect(wrapper.find(GlFormText).exists()).toBe(true); + expect(wrapper.find(IntegrationHelpText).exists()).toBe(true); + }); + + it('should render the label correctly', () => { + wrapper = createComponent(); + + expect(findFormGroupLabel().text()).toBe('Enable foo'); + }); + + it('should render IntegrationView properly', () => { + wrapper = createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js new file mode 100644 index 00000000000..fcc27d8faaf --- /dev/null +++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; + +import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue'; +import IntegrationView from '~/profile/preferences/components/integration_view.vue'; +import { integrationViews, userFields } from '../mock_data'; + +describe('ProfilePreferences component', () => { + let wrapper; + const defaultProvide = { + integrationViews: [], + userFields, + }; + + function createComponent(options = {}) { + const { props = {}, provide = {} } = options; + return shallowMount(ProfilePreferences, { + provide: { + ...defaultProvide, + ...provide, + }, + propsData: props, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should not render Integrations section', () => { + wrapper = createComponent(); + const views = wrapper.findAll(IntegrationView); + const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]'); + const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]'); + + expect(divider.exists()).toBe(false); + expect(heading.exists()).toBe(false); + expect(views).toHaveLength(0); + }); + + it('should render Integration section', () => { + wrapper = createComponent({ provide: { integrationViews } }); + const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]'); + const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]'); + const views = wrapper.findAll(IntegrationView); + + expect(divider.exists()).toBe(true); + expect(heading.exists()).toBe(true); + expect(views).toHaveLength(integrationViews.length); + }); + + it('should render ProfilePreferences properly', () => { + wrapper = createComponent({ provide: { integrationViews } }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/profile/preferences/mock_data.js b/spec/frontend/profile/preferences/mock_data.js new file mode 100644 index 00000000000..d07d5f565dc --- /dev/null +++ b/spec/frontend/profile/preferences/mock_data.js @@ -0,0 +1,18 @@ +export const integrationViews = [ + { + name: 'sourcegraph', + help_link: 'http://foo.com/help', + message: 'Click %{linkStart}Foo%{linkEnd}!', + message_url: 'http://foo.com', + }, + { + name: 'gitpod', + help_link: 'http://bar.com/help', + message: 'Click %{linkStart}Bar%{linkEnd}!', + message_url: 'http://bar.com', + }, +]; + +export const userFields = { + foo_enabled: true, +}; diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap index a0fd6012546..4eb5060cb0a 100644 --- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap +++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap @@ -57,7 +57,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` </gl-alert-stub> <p> - This action cannot be undone. You will lose the project's repository and all content: issues, merge requests, etc. + This action cannot be undone. You will lose this project's repository and all content: issues, merge requests, etc. </p> <p diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap index ff0351bd099..ac87fe893b9 100644 --- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap +++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap @@ -11,7 +11,6 @@ exports[`StatisticsList matches the snapshot 1`] = ` 4 pipelines </strong> </li> - <li> <span> Successful: @@ -21,7 +20,6 @@ exports[`StatisticsList matches the snapshot 1`] = ` 2 pipelines </strong> </li> - <li> <span> Failed: @@ -31,7 +29,6 @@ exports[`StatisticsList matches the snapshot 1`] = ` 2 pipelines </strong> </li> - <li> <span> Success ratio: @@ -41,5 +38,14 @@ exports[`StatisticsList matches the snapshot 1`] = ` 50% </strong> </li> + <li> + <span> + Total duration: + </span> + + <strong> + 00:01:56 + </strong> + </li> </ul> `; diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js index 883f2bec5f7..0dd3407dbbc 100644 --- a/spec/frontend/projects/pipelines/charts/components/app_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js @@ -45,7 +45,7 @@ describe('ProjectsPipelinesChartsApp', () => { expect(chart.exists()).toBeTruthy(); expect(chart.props('yAxisTitle')).toBe('Minutes'); expect(chart.props('xAxisTitle')).toBe('Commit'); - expect(chart.props('data')).toBe(wrapper.vm.timesChartTransformedData); + expect(chart.props('bars')).toBe(wrapper.vm.timesChartTransformedData); expect(chart.props('option')).toBe(wrapper.vm.$options.timesChartOptions); }); }); diff --git a/spec/frontend/projects/pipelines/charts/mock_data.js b/spec/frontend/projects/pipelines/charts/mock_data.js index db5164c8f99..84e0ccb828a 100644 --- a/spec/frontend/projects/pipelines/charts/mock_data.js +++ b/spec/frontend/projects/pipelines/charts/mock_data.js @@ -3,6 +3,7 @@ export const counts = { success: 2, total: 4, successRatio: 50, + totalDuration: 116158, }; export const timesChartData = { diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index 62aeb4ddee5..7e74a5deee1 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -1,7 +1,8 @@ -import { shallowMount, mount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import ServiceDeskRoot from '~/projects/settings_service_desk/components/service_desk_root.vue'; +import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -24,65 +25,6 @@ describe('ServiceDeskRoot', () => { } }); - it('fetches incoming email when there is no incoming email provided', () => { - axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK); - - wrapper = shallowMount(ServiceDeskRoot, { - propsData: { - initialIsEnabled: true, - initialIncomingEmail: '', - endpoint, - }, - }); - - return wrapper.vm - .$nextTick() - .then(waitForPromises) - .then(() => { - expect(axiosMock.history.get).toHaveLength(1); - }); - }); - - it('does not fetch incoming email when there is an incoming email provided', () => { - axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK); - - wrapper = shallowMount(ServiceDeskRoot, { - propsData: { - initialIsEnabled: true, - initialIncomingEmail, - endpoint, - }, - }); - - return wrapper.vm - .$nextTick() - .then(waitForPromises) - .then(() => { - expect(axiosMock.history.get).toHaveLength(0); - }); - }); - - it('shows an error message when incoming email is not fetched correctly', () => { - axiosMock.onGet(endpoint).networkError(); - - wrapper = shallowMount(ServiceDeskRoot, { - propsData: { - initialIsEnabled: true, - initialIncomingEmail: '', - endpoint, - }, - }); - - return wrapper.vm - .$nextTick() - .then(waitForPromises) - .then(() => { - expect(wrapper.html()).toContain( - 'An error occurred while fetching the Service Desk address.', - ); - }); - }); - it('sends a request to toggle service desk off when the toggle is clicked from the on state', () => { axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK); @@ -221,4 +163,18 @@ describe('ServiceDeskRoot', () => { expect(wrapper.html()).toContain('An error occured while making the changes:'); }); }); + + it('passes customEmail through updatedCustomEmail correctly', () => { + const customEmail = 'foo'; + + wrapper = mount(ServiceDeskRoot, { + propsData: { + initialIsEnabled: true, + endpoint, + customEmail, + }, + }); + + expect(wrapper.find(ServiceDeskSetting).props('customEmail')).toEqual(customEmail); + }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index cb46751f66a..173a7fc4e11 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -13,6 +13,7 @@ describe('ServiceDeskSetting', () => { }); const findTemplateDropdown = () => wrapper.find('#service-desk-template-select'); + const findIncomingEmail = () => wrapper.find('[data-testid="incoming-email"]'); describe('when isEnabled=true', () => { describe('only isEnabled', () => { @@ -35,7 +36,7 @@ describe('ServiceDeskSetting', () => { it('should see loading spinner and not the incoming email', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.find('.incoming-email').exists()).toBe(false); + expect(findIncomingEmail().exists()).toBe(false); }); }); }); @@ -73,7 +74,7 @@ describe('ServiceDeskSetting', () => { }); it('should see email and not the loading spinner', () => { - expect(wrapper.find('.incoming-email').element.value).toEqual(incomingEmail); + expect(findIncomingEmail().element.value).toEqual(incomingEmail); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); }); @@ -85,6 +86,45 @@ describe('ServiceDeskSetting', () => { }); }); + describe('with customEmail', () => { + describe('customEmail is different than incomingEmail', () => { + const incomingEmail = 'foo@bar.com'; + const customEmail = 'custom@bar.com'; + + beforeEach(() => { + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + incomingEmail, + customEmail, + }, + }); + }); + + it('should see custom email', () => { + expect(findIncomingEmail().element.value).toEqual(customEmail); + }); + }); + + describe('customEmail is the same as incomingEmail', () => { + const email = 'foo@bar.com'; + + beforeEach(() => { + wrapper = mount(ServiceDeskSetting, { + propsData: { + isEnabled: true, + incomingEmail: email, + customEmail: email, + }, + }); + }); + + it('should see custom email', () => { + expect(findIncomingEmail().element.value).toEqual(email); + }); + }); + }); + describe('templates dropdown', () => { it('renders a dropdown to choose a template', () => { wrapper = shallowMount(ServiceDeskSetting, { diff --git a/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js b/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js index f9e4d55245a..3b960a95db4 100644 --- a/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js +++ b/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js @@ -19,24 +19,6 @@ describe('ServiceDeskService', () => { axiosMock.restore(); }); - describe('fetchIncomingEmail', () => { - it('makes a request to fetch incoming email', () => { - axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse); - - return service.fetchIncomingEmail().then(response => { - expect(response.data).toEqual(dummyResponse); - }); - }); - - it('fails on error response', () => { - axiosMock.onGet(endpoint).networkError(); - - return service.fetchIncomingEmail().catch(error => { - expect(error.message).toBe(errorMessage); - }); - }); - }); - describe('toggleServiceDesk', () => { it('makes a request to set service desk', () => { axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse); diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js index 437a2116f5c..2460851a6a4 100644 --- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js +++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js @@ -27,7 +27,8 @@ describe('PrometheusMetrics', () => { expect(prometheusMetrics.$monitoredMetricsEmpty).toBeDefined(); expect(prometheusMetrics.$monitoredMetricsList).toBeDefined(); expect(prometheusMetrics.$missingEnvVarPanel).toBeDefined(); - expect(prometheusMetrics.$panelToggle).toBeDefined(); + expect(prometheusMetrics.$panelToggleRight).toBeDefined(); + expect(prometheusMetrics.$panelToggleDown).toBeDefined(); expect(prometheusMetrics.$missingEnvVarMetricCount).toBeDefined(); expect(prometheusMetrics.$missingEnvVarMetricsList).toBeDefined(); }); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js index ef22979ca7d..3276ef911e3 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js @@ -22,7 +22,7 @@ describe('tags list row', () => { let wrapper; const [tag] = [...tagsListResponse.data]; - const defaultProps = { tag, isDesktop: true, index: 0 }; + const defaultProps = { tag, isMobile: false, index: 0 }; const findCheckbox = () => wrapper.find(GlFormCheckbox); const findName = () => wrapper.find('[data-testid="name"]'); @@ -114,7 +114,7 @@ describe('tags list row', () => { }); it('on mobile has mw-s class', () => { - mountComponent({ ...defaultProps, isDesktop: false }); + mountComponent({ ...defaultProps, isMobile: true }); expect(findName().classes('mw-s')).toBe(true); }); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js index 401202026bb..ebeaa8ff870 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js @@ -14,7 +14,7 @@ describe('Tags List', () => { const findDeleteButton = () => wrapper.find(GlButton); const findListTitle = () => wrapper.find('[data-testid="list-title"]'); - const mountComponent = (propsData = { tags, isDesktop: true }) => { + const mountComponent = (propsData = { tags, isMobile: false }) => { wrapper = shallowMount(component, { propsData, }); @@ -41,15 +41,15 @@ describe('Tags List', () => { describe('delete button', () => { it.each` - inputTags | isDesktop | isVisible - ${tags} | ${true} | ${true} - ${tags} | ${false} | ${false} - ${readOnlyTags} | ${true} | ${false} - ${readOnlyTags} | ${false} | ${false} + inputTags | isMobile | isVisible + ${tags} | ${false} | ${true} + ${tags} | ${true} | ${false} + ${readOnlyTags} | ${false} | ${false} + ${readOnlyTags} | ${true} | ${false} `( - 'is $isVisible that delete button exists when tags is $inputTags and isDesktop is $isDesktop', - ({ inputTags, isDesktop, isVisible }) => { - mountComponent({ tags: inputTags, isDesktop }); + 'is $isVisible that delete button exists when tags is $inputTags and isMobile is $isMobile', + ({ inputTags, isMobile, isVisible }) => { + mountComponent({ tags: inputTags, isMobile }); expect(findDeleteButton().exists()).toBe(isVisible); }, @@ -110,12 +110,6 @@ describe('Tags List', () => { expect(rows.at(0).attributes()).toMatchObject({ first: 'true', - isdesktop: 'true', - }); - - // The list has only two tags and for some reasons .at(-1) does not work - expect(rows.at(1).attributes()).toMatchObject({ - isdesktop: 'true', }); }); diff --git a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js index b4471ab8122..551d1eee68d 100644 --- a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js @@ -1,6 +1,6 @@ import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedDropdown } from '@gitlab/ui'; +import { GlDropdown } from '@gitlab/ui'; import Tracking from '~/tracking'; import * as getters from '~/registry/explorer/stores/getters'; import QuickstartDropdown from '~/registry/explorer/components/list_page/cli_commands.vue'; @@ -23,7 +23,7 @@ describe('cli_commands', () => { let wrapper; let store; - const findDropdownButton = () => wrapper.find(GlDeprecatedDropdown); + const findDropdownButton = () => wrapper.find(GlDropdown); const findCodeInstruction = () => wrapper.findAll(CodeInstruction); const mountComponent = () => { diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js index ce446e6d93e..9f7a2758ae1 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js @@ -19,7 +19,7 @@ describe('Image List Row', () => { let wrapper; const item = imagesListResponse.data[0]; - const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]'); + const findDetailsLink = () => wrapper.find('[data-testid="details-link"]'); const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]'); const findDeleteBtn = () => wrapper.find(DeleteButton); const findClipboardButton = () => wrapper.find(ClipboardButton); @@ -67,7 +67,12 @@ describe('Image List Row', () => { mountComponent(); const link = findDetailsLink(); expect(link.html()).toContain(item.path); - expect(link.props('to').name).toBe('details'); + expect(link.props('to')).toMatchObject({ + name: 'details', + params: { + id: item.id, + }, + }); }); it('contains a clipboard button', () => { diff --git a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js index b906e44a4f7..d730bfcde24 100644 --- a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js +++ b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js @@ -32,6 +32,10 @@ describe('Registry Breadcrumb', () => { { name: 'baz', meta: { nameGenerator } }, ]; + const state = { + imageDetails: { foo: 'bar' }, + }; + const findDivider = () => wrapper.find('.js-divider'); const findRootRoute = () => wrapper.find({ ref: 'rootRouteLink' }); const findChildRoute = () => wrapper.find({ ref: 'childRouteLink' }); @@ -52,6 +56,9 @@ describe('Registry Breadcrumb', () => { routes, }, }, + $store: { + state, + }, }, }); }; @@ -80,7 +87,7 @@ describe('Registry Breadcrumb', () => { }); it('the link text is calculated by nameGenerator', () => { - expect(nameGenerator).toHaveBeenCalledWith(routes[0]); + expect(nameGenerator).toHaveBeenCalledWith(state); expect(nameGenerator).toHaveBeenCalledTimes(1); }); }); @@ -104,7 +111,7 @@ describe('Registry Breadcrumb', () => { }); it('the link text is calculated by nameGenerator', () => { - expect(nameGenerator).toHaveBeenCalledWith(routes[1]); + expect(nameGenerator).toHaveBeenCalledWith(state); expect(nameGenerator).toHaveBeenCalledTimes(2); }); }); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index a7ffed4c9fd..da5f1840b5c 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -97,3 +97,14 @@ export const imagePagination = { totalPages: 2, nextPage: 2, }; + +export const imageDetailsMock = { + id: 1, + name: 'rails-32309', + path: 'gitlab-org/gitlab-test/rails-32309', + project_id: 1, + location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-32309', + created_at: '2020-06-29T10:23:47.838Z', + cleanup_policy_started_at: null, + delete_api_path: 'http://0.0.0.0:3000/api/v4/projects/1/registry/repositories/1', +}; diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 86b52c4f06a..c09b7e0c067 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -14,9 +14,10 @@ import { SET_TAGS_LIST_SUCCESS, SET_TAGS_PAGINATION, SET_INITIAL_STATE, + SET_IMAGE_DETAILS, } from '~/registry/explorer/stores/mutation_types'; -import { tagsListResponse } from '../mock_data'; +import { tagsListResponse, imageDetailsMock } from '../mock_data'; import { DeleteModal } from '../stubs'; describe('Details Page', () => { @@ -33,8 +34,7 @@ describe('Details Page', () => { const findEmptyTagsState = () => wrapper.find(EmptyTagsState); const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); - const routeIdGenerator = override => - window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar', ...override })); + const routeId = 1; const tagsArrayToSelectedTags = tags => tags.reduce((acc, c) => { @@ -42,7 +42,7 @@ describe('Details Page', () => { return acc; }, {}); - const mountComponent = ({ options, routeParams } = {}) => { + const mountComponent = ({ options } = {}) => { wrapper = shallowMount(component, { store, stubs: { @@ -51,7 +51,7 @@ describe('Details Page', () => { mocks: { $route: { params: { - id: routeIdGenerator(routeParams), + id: routeId, }, }, }, @@ -65,6 +65,7 @@ describe('Details Page', () => { dispatchSpy.mockResolvedValue(); store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data); store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers); + store.commit(SET_IMAGE_DETAILS, imageDetailsMock); jest.spyOn(Tracking, 'event'); }); @@ -73,6 +74,13 @@ describe('Details Page', () => { wrapper = null; }); + describe('lifecycle events', () => { + it('calls the appropriate action on mount', () => { + mountComponent(); + expect(dispatchSpy).toHaveBeenCalledWith('requestImageDetailsAndTagsList', routeId); + }); + }); + describe('when isLoading is true', () => { beforeEach(() => { store.commit(SET_MAIN_LOADING, true); @@ -124,7 +132,7 @@ describe('Details Page', () => { it('has the correct props bound', () => { expect(findTagsList().props()).toMatchObject({ - isDesktop: true, + isMobile: false, tags: store.state.tags, }); }); @@ -194,8 +202,7 @@ describe('Details Page', () => { dispatchSpy.mockResolvedValue(); findPagination().vm.$emit(GlPagination.model.event, 2); expect(store.dispatch).toHaveBeenCalledWith('requestTagsList', { - params: wrapper.vm.$route.params.id, - pagination: { page: 2 }, + page: 2, }); }); }); @@ -227,7 +234,6 @@ describe('Details Page', () => { findDeleteModal().vm.$emit('confirmDelete'); expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', { tag: store.state.tags[0], - params: routeIdGenerator(), }); }); }); @@ -242,7 +248,6 @@ describe('Details Page', () => { findDeleteModal().vm.$emit('confirmDelete'); expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', { ids: store.state.tags.map(t => t.name), - params: routeIdGenerator(), }); }); }); @@ -257,7 +262,7 @@ describe('Details Page', () => { it('has the correct props', () => { mountComponent(); - expect(findDetailsHeader().props()).toEqual({ imageName: 'foo' }); + expect(findDetailsHeader().props()).toEqual({ imageName: imageDetailsMock.name }); }); }); @@ -293,10 +298,14 @@ describe('Details Page', () => { }; describe('when expiration_policy_started is not null', () => { - const routeParams = { cleanup_policy_started_at: Date.now().toString() }; - + beforeEach(() => { + store.commit(SET_IMAGE_DETAILS, { + ...imageDetailsMock, + cleanup_policy_started_at: Date.now().toString(), + }); + }); it('exists', () => { - mountComponent({ routeParams }); + mountComponent(); expect(findPartialCleanupAlert().exists()).toBe(true); }); @@ -304,13 +313,13 @@ describe('Details Page', () => { it('has the correct props', () => { store.commit(SET_INITIAL_STATE, { ...config }); - mountComponent({ routeParams }); + mountComponent(); expect(findPartialCleanupAlert().props()).toEqual({ ...config }); }); it('dismiss hides the component', async () => { - mountComponent({ routeParams }); + mountComponent(); expect(findPartialCleanupAlert().exists()).toBe(true); findPartialCleanupAlert().vm.$emit('dismiss'); diff --git a/spec/frontend/registry/explorer/stores/actions_spec.js b/spec/frontend/registry/explorer/stores/actions_spec.js index fb93ab06ca8..dcd4d8015a4 100644 --- a/spec/frontend/registry/explorer/stores/actions_spec.js +++ b/spec/frontend/registry/explorer/stores/actions_spec.js @@ -1,18 +1,29 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'helpers/test_constants'; +import createFlash from '~/flash'; +import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/registry/explorer/stores/actions'; import * as types from '~/registry/explorer/stores/mutation_types'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; import { reposServerResponse, registryServerResponse } from '../mock_data'; +import * as utils from '~/registry/explorer/utils'; +import { + FETCH_IMAGES_LIST_ERROR_MESSAGE, + FETCH_TAGS_LIST_ERROR_MESSAGE, + FETCH_IMAGE_DETAILS_ERROR_MESSAGE, +} from '~/registry/explorer/constants/index'; jest.mock('~/flash.js'); +jest.mock('~/registry/explorer/utils'); describe('Actions RegistryExplorer Store', () => { let mock; const endpoint = `${TEST_HOST}/endpoint.json`; + const url = `${endpoint}/1}`; + jest.spyOn(utils, 'pathGenerator').mockReturnValue(url); + beforeEach(() => { mock = new MockAdapter(axios); }); @@ -123,7 +134,7 @@ describe('Actions RegistryExplorer Store', () => { ], [], () => { - expect(createFlash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); done(); }, ); @@ -131,15 +142,12 @@ describe('Actions RegistryExplorer Store', () => { }); describe('fetch tags list', () => { - const url = `${endpoint}/1}`; - const params = window.btoa(JSON.stringify({ tags_path: `${endpoint}/1}` })); - it('sets the tagsList', done => { mock.onGet(url).replyOnce(200, registryServerResponse, {}); testAction( actions.requestTagsList, - { params }, + {}, {}, [ { type: types.SET_MAIN_LOADING, payload: true }, @@ -158,7 +166,7 @@ describe('Actions RegistryExplorer Store', () => { it('should create flash on error', done => { testAction( actions.requestTagsList, - { params }, + {}, {}, [ { type: types.SET_MAIN_LOADING, payload: true }, @@ -166,7 +174,7 @@ describe('Actions RegistryExplorer Store', () => { ], [], () => { - expect(createFlash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ message: FETCH_TAGS_LIST_ERROR_MESSAGE }); done(); }, ); @@ -176,8 +184,6 @@ describe('Actions RegistryExplorer Store', () => { describe('request delete single tag', () => { it('successfully performs the delete request', done => { const deletePath = 'delete/path'; - const params = window.btoa(JSON.stringify({ tags_path: `${endpoint}/1}`, id: 1 })); - mock.onDelete(deletePath).replyOnce(200); testAction( @@ -186,7 +192,6 @@ describe('Actions RegistryExplorer Store', () => { tag: { destroy_path: deletePath, }, - params, }, { tagsPagination: {}, @@ -202,7 +207,7 @@ describe('Actions RegistryExplorer Store', () => { }, { type: 'requestTagsList', - payload: { pagination: {}, params }, + payload: {}, }, ], done, @@ -227,18 +232,55 @@ describe('Actions RegistryExplorer Store', () => { }); }); - describe('request delete multiple tags', () => { - const url = `project-path/registry/repository/foo/tags`; - const params = window.btoa(JSON.stringify({ tags_path: `${url}?format=json` })); + describe('requestImageDetailsAndTagsList', () => { + it('sets the imageDetails and dispatch requestTagsList', done => { + const resolvedValue = { foo: 'bar' }; + jest.spyOn(Api, 'containerRegistryDetails').mockResolvedValue({ data: resolvedValue }); + + testAction( + actions.requestImageDetailsAndTagsList, + 1, + {}, + [ + { type: types.SET_MAIN_LOADING, payload: true }, + { type: types.SET_IMAGE_DETAILS, payload: resolvedValue }, + ], + [ + { + type: 'requestTagsList', + }, + ], + done, + ); + }); + + it('should create flash on error', done => { + jest.spyOn(Api, 'containerRegistryDetails').mockRejectedValue(); + testAction( + actions.requestImageDetailsAndTagsList, + 1, + {}, + [ + { type: types.SET_MAIN_LOADING, payload: true }, + { type: types.SET_MAIN_LOADING, payload: false }, + ], + [], + () => { + expect(createFlash).toHaveBeenCalledWith({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE }); + done(); + }, + ); + }); + }); + describe('request delete multiple tags', () => { it('successfully performs the delete request', done => { - mock.onDelete(`${url}/bulk_destroy`).replyOnce(200); + mock.onDelete(url).replyOnce(200); testAction( actions.requestDeleteTags, { ids: [1, 2], - params, }, { tagsPagination: {}, @@ -254,7 +296,7 @@ describe('Actions RegistryExplorer Store', () => { }, { type: 'requestTagsList', - payload: { pagination: {}, params }, + payload: {}, }, ], done, @@ -268,7 +310,6 @@ describe('Actions RegistryExplorer Store', () => { actions.requestDeleteTags, { ids: [1, 2], - params, }, { tagsPagination: {}, diff --git a/spec/frontend/registry/explorer/stores/mutations_spec.js b/spec/frontend/registry/explorer/stores/mutations_spec.js index 4ca0211cdc3..1908d3f0350 100644 --- a/spec/frontend/registry/explorer/stores/mutations_spec.js +++ b/spec/frontend/registry/explorer/stores/mutations_spec.js @@ -121,4 +121,13 @@ describe('Mutations Registry Explorer Store', () => { expect(mockState).toEqual(expectedState); }); }); + + describe('SET_IMAGE_DETAILS', () => { + it('should set imageDetails', () => { + const expectedState = { ...mockState, imageDetails: { foo: 'bar' } }; + mutations[types.SET_IMAGE_DETAILS](mockState, { foo: 'bar' }); + + expect(mockState).toEqual(expectedState); + }); + }); }); diff --git a/spec/frontend/registry/explorer/utils_spec.js b/spec/frontend/registry/explorer/utils_spec.js new file mode 100644 index 00000000000..0cd4a1cec29 --- /dev/null +++ b/spec/frontend/registry/explorer/utils_spec.js @@ -0,0 +1,45 @@ +import { pathGenerator } from '~/registry/explorer/utils'; + +describe('Utils', () => { + describe('pathGenerator', () => { + const imageDetails = { + path: 'foo/bar/baz', + name: 'baz', + id: 1, + }; + + it('returns the fetch url when no ending is passed', () => { + expect(pathGenerator(imageDetails)).toBe('/foo/bar/registry/repository/1/tags?format=json'); + }); + + it('returns the url with an ending when is passed', () => { + expect(pathGenerator(imageDetails, '/foo')).toBe('/foo/bar/registry/repository/1/tags/foo'); + }); + + it.each` + path | name | result + ${'foo/foo'} | ${''} | ${'/foo/foo/registry/repository/1/tags?format=json'} + ${'foo/foo/foo'} | ${'foo'} | ${'/foo/foo/registry/repository/1/tags?format=json'} + ${'baz/foo/foo/foo'} | ${'foo'} | ${'/baz/foo/foo/registry/repository/1/tags?format=json'} + ${'baz/foo/foo/foo'} | ${'foo'} | ${'/baz/foo/foo/registry/repository/1/tags?format=json'} + ${'foo/foo/baz/foo/foo'} | ${'foo/foo'} | ${'/foo/foo/baz/registry/repository/1/tags?format=json'} + ${'foo/foo/baz/foo/bar'} | ${'foo/bar'} | ${'/foo/foo/baz/registry/repository/1/tags?format=json'} + ${'baz/foo/foo'} | ${'foo'} | ${'/baz/foo/registry/repository/1/tags?format=json'} + ${'baz/foo/bar'} | ${'foo'} | ${'/baz/foo/bar/registry/repository/1/tags?format=json'} + `('returns the correct path when path is $path and name is $name', ({ name, path, result }) => { + expect(pathGenerator({ id: 1, name, path })).toBe(result); + }); + + it('returns the url unchanged when imageDetails have no name', () => { + const imageDetailsWithoutName = { + path: 'foo/bar/baz', + name: '', + id: 1, + }; + + expect(pathGenerator(imageDetailsWithoutName)).toBe( + '/foo/bar/baz/registry/repository/1/tags?format=json', + ); + }); + }); +}); diff --git a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap index 69953fb5e03..2ceb2655d40 100644 --- a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap +++ b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap @@ -123,7 +123,7 @@ exports[`Expiration Policy Form renders 1`] = ` disabled="true" id="expiration-policy-name-matching" noresize="true" - placeholder=".*" + placeholder="" trim="" value="" /> diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index 25c108e45bc..f49d3d7b716 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -5,9 +5,12 @@ Object { "data": Array [ Object { "_links": Object { + "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed", + "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=closed", "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit", - "issuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened", - "mergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened", + "mergedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=merged", + "openedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened", + "openedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened", "self": "http://localhost/releases-namespace/releases-project/-/releases/v1.1", "selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1", }, @@ -15,7 +18,7 @@ Object { "count": 8, "links": Array [ Object { - "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-3", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-3", "external": true, "id": "gid://gitlab/Releases::Link/13", "linkType": "image", @@ -23,7 +26,7 @@ Object { "url": "https://example.com/image", }, Object { - "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-2", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-2", "external": true, "id": "gid://gitlab/Releases::Link/12", "linkType": "package", @@ -31,7 +34,7 @@ Object { "url": "https://example.com/package", }, Object { - "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-1", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-1", "external": false, "id": "gid://gitlab/Releases::Link/11", "linkType": "runbook", @@ -39,7 +42,7 @@ Object { "url": "http://localhost/releases-namespace/releases-project/runbook", }, Object { - "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/linux-amd64", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/linux-amd64", "external": true, "id": "gid://gitlab/Releases::Link/10", "linkType": "other", @@ -130,9 +133,12 @@ exports[`releases/util.js convertOneReleaseGraphQLResponse matches snapshot 1`] Object { "data": Object { "_links": Object { + "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed", + "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=closed", "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit", - "issuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened", - "mergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened", + "mergedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=merged", + "openedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened", + "openedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened", "self": "http://localhost/releases-namespace/releases-project/-/releases/v1.1", "selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1", }, @@ -140,7 +146,7 @@ Object { "count": 8, "links": Array [ Object { - "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-3", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-3", "external": true, "id": "gid://gitlab/Releases::Link/13", "linkType": "image", @@ -148,7 +154,7 @@ Object { "url": "https://example.com/image", }, Object { - "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-2", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-2", "external": true, "id": "gid://gitlab/Releases::Link/12", "linkType": "package", @@ -156,7 +162,7 @@ Object { "url": "https://example.com/package", }, Object { - "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-1", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-1", "external": false, "id": "gid://gitlab/Releases::Link/11", "linkType": "runbook", @@ -164,7 +170,7 @@ Object { "url": "http://localhost/releases-namespace/releases-project/runbook", }, Object { - "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/linux-amd64", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/linux-amd64", "external": true, "id": "gid://gitlab/Releases::Link/10", "linkType": "other", diff --git a/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap b/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap new file mode 100644 index 00000000000..e53ea6b2ec6 --- /dev/null +++ b/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`~/releases/components/issuable_stats.vue matches snapshot 1`] = ` +"<div class=\\"gl-display-flex gl-flex-direction-column gl-flex-shrink-0 gl-mr-6 gl-mb-5\\"><span class=\\"gl-mb-2\\"> + Items + <span class=\\"badge badge-muted badge-pill gl-badge sm\\"><!----> 10</span></span> + <div class=\\"gl-display-flex\\"><span data-testid=\\"open-stat\\" class=\\"gl-white-space-pre-wrap\\">Open: <a href=\\"path/to/opened/items\\" class=\\"gl-link\\">1</a></span> <span class=\\"gl-mx-2\\">•</span> <span data-testid=\\"merged-stat\\" class=\\"gl-white-space-pre-wrap\\">Merged: <a href=\\"path/to/merged/items\\" class=\\"gl-link\\">7</a></span> <span class=\\"gl-mx-2\\">•</span> <span data-testid=\\"closed-stat\\" class=\\"gl-white-space-pre-wrap\\">Closed: <a href=\\"path/to/closed/items\\" class=\\"gl-link\\">2</a></span></div> +</div>" +`; diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index d92bdc3b99a..1d409b5b590 100644 --- a/spec/frontend/releases/components/app_edit_new_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -24,9 +24,10 @@ describe('Release edit/new component', () => { state = { release, markdownDocsPath: 'path/to/markdown/docs', - updateReleaseApiDocsPath: 'path/to/update/release/api/docs', releasesPagePath: 'path/to/releases/page', projectId: '8', + groupId: '42', + groupMilestonesAvailable: true, }; actions = { diff --git a/spec/frontend/releases/components/issuable_stats_spec.js b/spec/frontend/releases/components/issuable_stats_spec.js new file mode 100644 index 00000000000..d8211ec2adc --- /dev/null +++ b/spec/frontend/releases/components/issuable_stats_spec.js @@ -0,0 +1,114 @@ +import { GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import IssuableStats from '~/releases/components/issuable_stats.vue'; + +describe('~/releases/components/issuable_stats.vue', () => { + let wrapper; + let defaultProps; + + const createComponent = propUpdates => { + wrapper = mount(IssuableStats, { + propsData: { + ...defaultProps, + ...propUpdates, + }, + }); + }; + + const findOpenStatLink = () => wrapper.find('[data-testid="open-stat"]').find(GlLink); + const findMergedStatLink = () => wrapper.find('[data-testid="merged-stat"]').find(GlLink); + const findClosedStatLink = () => wrapper.find('[data-testid="closed-stat"]').find(GlLink); + + beforeEach(() => { + defaultProps = { + label: 'Items', + total: 10, + closed: 2, + merged: 7, + openedPath: 'path/to/opened/items', + closedPath: 'path/to/closed/items', + mergedPath: 'path/to/merged/items', + }; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('matches snapshot', () => { + createComponent(); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + describe('when only total and closed counts are provided', () => { + beforeEach(() => { + createComponent({ merged: undefined, mergedPath: undefined }); + }); + + it('renders a label with the total count; also, the opened count and the closed count', () => { + expect(trimText(wrapper.text())).toMatchInterpolatedText('Items 10 Open: 8 • Closed: 2'); + }); + }); + + describe('when only total, merged, and closed counts are provided', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a label with the total count; also, the opened count, the merged count, and the closed count', () => { + expect(wrapper.text()).toMatchInterpolatedText('Items 10 Open: 1 • Merged: 7 • Closed: 2'); + }); + }); + + describe('when path parameters are provided', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the "open" stat as a link', () => { + const link = findOpenStatLink(); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(defaultProps.openedPath); + }); + + it('renders the "merged" stat as a link', () => { + const link = findMergedStatLink(); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(defaultProps.mergedPath); + }); + + it('renders the "closed" stat as a link', () => { + const link = findClosedStatLink(); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(defaultProps.closedPath); + }); + }); + + describe('when path parameters are not provided', () => { + beforeEach(() => { + createComponent({ + openedPath: undefined, + closedPath: undefined, + mergedPath: undefined, + }); + }); + + it('does not render the "open" stat as a link', () => { + expect(findOpenStatLink().exists()).toBe(false); + }); + + it('does not render the "merged" stat as a link', () => { + expect(findMergedStatLink().exists()).toBe(false); + }); + + it('does not render the "closed" stat as a link', () => { + expect(findClosedStatLink().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js index 45f4eaa01a9..bb34693c757 100644 --- a/spec/frontend/releases/components/release_block_milestone_info_spec.js +++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js @@ -31,7 +31,8 @@ describe('Release block milestone info', () => { const milestoneProgressBarContainer = () => wrapper.find('.js-milestone-progress-bar-container'); const milestoneListContainer = () => wrapper.find('.js-milestone-list-container'); - const issuesContainer = () => wrapper.find('.js-issues-container'); + const issuesContainer = () => wrapper.find('[data-testid="issue-stats"]'); + const mergeRequestsContainer = () => wrapper.find('[data-testid="merge-request-stats"]'); describe('with default props', () => { beforeEach(() => factory({ milestones })); @@ -188,66 +189,32 @@ describe('Release block milestone info', () => { expectAllZeros(); }); - describe('Issue links', () => { - const findOpenIssuesLink = () => wrapper.find({ ref: 'openIssuesLink' }); - const findOpenIssuesText = () => wrapper.find({ ref: 'openIssuesText' }); - const findClosedIssuesLink = () => wrapper.find({ ref: 'closedIssuesLink' }); - const findClosedIssuesText = () => wrapper.find({ ref: 'closedIssuesText' }); - - describe('when openIssuePath is provided', () => { - const openIssuesPath = '/path/to/open/issues'; - - beforeEach(() => { - return factory({ milestones, openIssuesPath }); - }); - - it('renders the open issues as a link', () => { - expect(findOpenIssuesLink().exists()).toBe(true); - expect(findOpenIssuesText().exists()).toBe(false); - }); - - it('renders the open issues link with the correct href', () => { - expect(findOpenIssuesLink().attributes().href).toBe(openIssuesPath); - }); - }); - - describe('when openIssuePath is not provided', () => { - beforeEach(() => { - return factory({ milestones }); - }); + describe('if the API response is missing the "mr_stats" property', () => { + beforeEach(() => factory({ milestones })); - it('renders the open issues as plain text', () => { - expect(findOpenIssuesLink().exists()).toBe(false); - expect(findOpenIssuesText().exists()).toBe(true); - }); + it('does not render merge request stats', () => { + expect(mergeRequestsContainer().exists()).toBe(false); }); + }); - describe('when closedIssuePath is provided', () => { - const closedIssuesPath = '/path/to/closed/issues'; - - beforeEach(() => { - return factory({ milestones, closedIssuesPath }); - }); - - it('renders the closed issues as a link', () => { - expect(findClosedIssuesLink().exists()).toBe(true); - expect(findClosedIssuesText().exists()).toBe(false); - }); + describe('if the API response includes the "mr_stats" property', () => { + beforeEach(() => { + milestones = milestones.map(m => ({ + ...m, + mrStats: { + total: 15, + merged: 12, + closed: 1, + }, + })); - it('renders the closed issues link with the correct href', () => { - expect(findClosedIssuesLink().attributes().href).toBe(closedIssuesPath); - }); + return factory({ milestones }); }); - describe('when closedIssuePath is not provided', () => { - beforeEach(() => { - return factory({ milestones }); - }); - - it('renders the closed issues as plain text', () => { - expect(findClosedIssuesLink().exists()).toBe(false); - expect(findClosedIssuesText().exists()).toBe(true); - }); + it('renders merge request stats', () => { + expect(trimText(mergeRequestsContainer().text())).toBe( + 'Merge Requests 30 Open: 4 • Merged: 24 • Closed: 2', + ); }); }); }); diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js new file mode 100644 index 00000000000..c089ee3cc38 --- /dev/null +++ b/spec/frontend/releases/components/releases_sort_spec.js @@ -0,0 +1,66 @@ +import Vuex from 'vuex'; +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import ReleasesSort from '~/releases/components/releases_sort.vue'; +import createStore from '~/releases/stores'; +import createListModule from '~/releases/stores/modules/list'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('~/releases/components/releases_sort.vue', () => { + let wrapper; + let store; + let listModule; + const projectId = 8; + + const createComponent = () => { + listModule = createListModule({ projectId }); + + store = createStore({ + modules: { + list: listModule, + }, + }); + + store.dispatch = jest.fn(); + + wrapper = shallowMount(ReleasesSort, { + store, + stubs: { + GlSortingItem, + }, + localVue, + }); + }; + + const findReleasesSorting = () => wrapper.find(GlSorting); + const findSortingItems = () => wrapper.findAll(GlSortingItem); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + createComponent(); + }); + + it('has all the sortable items', () => { + expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length); + }); + + it('on sort change set sorting in vuex and emit event', () => { + findReleasesSorting().vm.$emit('sortDirectionChange'); + expect(store.dispatch).toHaveBeenCalledWith('list/setSorting', { sort: 'asc' }); + expect(wrapper.emitted('sort:changed')).toBeTruthy(); + }); + + it('on sort item click set sorting and emit event', () => { + const item = findSortingItems().at(0); + const { orderBy } = wrapper.vm.sortOptions[0]; + item.vm.$emit('click'); + expect(store.dispatch).toHaveBeenCalledWith('list/setSorting', { orderBy }); + expect(wrapper.emitted('sort:changed')).toBeTruthy(); + }); +}); diff --git a/spec/frontend/releases/components/tag_field_exsting_spec.js b/spec/frontend/releases/components/tag_field_exsting_spec.js index 70a195556df..d4110b57776 100644 --- a/spec/frontend/releases/components/tag_field_exsting_spec.js +++ b/spec/frontend/releases/components/tag_field_exsting_spec.js @@ -6,7 +6,6 @@ import createStore from '~/releases/stores'; import createDetailModule from '~/releases/stores/modules/detail'; const TEST_TAG_NAME = 'test-tag-name'; -const TEST_DOCS_PATH = '/help/test/docs/path'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -24,21 +23,11 @@ describe('releases/components/tag_field_existing', () => { const findInput = () => wrapper.find(GlFormInput); const findHelp = () => wrapper.find('[data-testid="tag-name-help"]'); - const findHelpLink = () => { - const link = findHelp().find('a'); - - return { - text: link.text(), - href: link.attributes('href'), - target: link.attributes('target'), - }; - }; beforeEach(() => { store = createStore({ modules: { detail: createDetailModule({ - updateReleaseApiDocsPath: TEST_DOCS_PATH, tagName: TEST_TAG_NAME, }), }, @@ -68,16 +57,8 @@ describe('releases/components/tag_field_existing', () => { createComponent(mount); expect(findHelp().text()).toMatchInterpolatedText( - 'Changing a Release tag is only supported via Releases API. More information', + "The tag name can't be changed for an existing release.", ); - - const helpLink = findHelpLink(); - - expect(helpLink).toEqual({ - text: 'More information', - href: TEST_DOCS_PATH, - target: '_blank', - }); }); }); }); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index d38f6766d4e..abd0db6a589 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -47,7 +47,6 @@ describe('Release detail actions', () => { releasesPagePath: 'path/to/releases/page', markdownDocsPath: 'path/to/markdown/docs', markdownPreviewPath: 'path/to/markdown/preview', - updateReleaseApiDocsPath: 'path/to/api/docs', }), ...getters, ...rootState, diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index f3e84262754..88eddc4019c 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -18,7 +18,6 @@ describe('Release detail mutations', () => { releasesPagePath: 'path/to/releases/page', markdownDocsPath: 'path/to/markdown/docs', markdownPreviewPath: 'path/to/markdown/preview', - updateReleaseApiDocsPath: 'path/to/api/docs', }); release = convertObjectPropsToCamelCase(originalRelease); }); diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js index 4e235e1d00f..35551b77dc4 100644 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ b/spec/frontend/releases/stores/modules/list/actions_spec.js @@ -6,6 +6,7 @@ import { fetchReleasesGraphQl, fetchReleasesRest, receiveReleasesError, + setSorting, } from '~/releases/stores/modules/list/actions'; import createState from '~/releases/stores/modules/list/state'; import * as types from '~/releases/stores/modules/list/mutation_types'; @@ -114,7 +115,7 @@ describe('Releases State actions', () => { it('makes a GraphQl query with a first variable', () => { expect(gqClient.query).toHaveBeenCalledWith({ query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE }, + variables: { fullPath: projectPath, first: PAGE_SIZE, sort: 'RELEASED_AT_DESC' }, }); }); }); @@ -127,7 +128,7 @@ describe('Releases State actions', () => { it('makes a GraphQl query with last and before variables', () => { expect(gqClient.query).toHaveBeenCalledWith({ query: allReleasesQuery, - variables: { fullPath: projectPath, last: PAGE_SIZE, before }, + variables: { fullPath: projectPath, last: PAGE_SIZE, before, sort: 'RELEASED_AT_DESC' }, }); }); }); @@ -140,7 +141,7 @@ describe('Releases State actions', () => { it('makes a GraphQl query with first and after variables', () => { expect(gqClient.query).toHaveBeenCalledWith({ query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, after }, + variables: { fullPath: projectPath, first: PAGE_SIZE, after, sort: 'RELEASED_AT_DESC' }, }); }); }); @@ -156,6 +157,29 @@ describe('Releases State actions', () => { ); }); }); + + describe('when the sort parameters are provided', () => { + it.each` + sort | orderBy | ReleaseSort + ${'asc'} | ${'released_at'} | ${'RELEASED_AT_ASC'} + ${'desc'} | ${'released_at'} | ${'RELEASED_AT_DESC'} + ${'asc'} | ${'created_at'} | ${'CREATED_ASC'} + ${'desc'} | ${'created_at'} | ${'CREATED_DESC'} + `( + 'correctly sets $ReleaseSort based on $sort and $orderBy', + ({ sort, orderBy, ReleaseSort }) => { + mockedState.sorting.sort = sort; + mockedState.sorting.orderBy = orderBy; + + fetchReleasesGraphQl(vuexParams, { before: undefined, after: undefined }); + + expect(gqClient.query).toHaveBeenCalledWith({ + query: allReleasesQuery, + variables: { fullPath: projectPath, first: PAGE_SIZE, sort: ReleaseSort }, + }); + }, + ); + }); }); describe('when the request is successful', () => { @@ -230,7 +254,11 @@ describe('Releases State actions', () => { }); it('makes a REST query with a page query parameter', () => { - expect(api.releases).toHaveBeenCalledWith(projectId, { page }); + expect(api.releases).toHaveBeenCalledWith(projectId, { + page, + order_by: 'released_at', + sort: 'desc', + }); }); }); }); @@ -302,4 +330,16 @@ describe('Releases State actions', () => { ); }); }); + + describe('setSorting', () => { + it('should commit SET_SORTING', () => { + return testAction( + setSorting, + { orderBy: 'released_at', sort: 'asc' }, + null, + [{ type: types.SET_SORTING, payload: { orderBy: 'released_at', sort: 'asc' } }], + [], + ); + }); + }); }); diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js index 521418cbddb..78071573072 100644 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js @@ -80,4 +80,16 @@ describe('Releases Store Mutations', () => { expect(stateCopy.graphQlPageInfo).toEqual({}); }); }); + + describe('SET_SORTING', () => { + it('should merge the sorting object with sort value', () => { + mutations[types.SET_SORTING](stateCopy, { sort: 'asc' }); + expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, sort: 'asc' }); + }); + + it('should merge the sorting object with order_by value', () => { + mutations[types.SET_SORTING](stateCopy, { orderBy: 'created_at' }); + expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, orderBy: 'created_at' }); + }); + }); }); diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js index e7b7766c0d0..fd00a524628 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -22,7 +22,7 @@ describe('releases/util.js', () => { tagName: 'tag-name', name: 'Release name', description: 'Release description', - milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }], + milestones: ['13.2', '13.3'], assets: { links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }], }, @@ -74,14 +74,14 @@ describe('releases/util.js', () => { }); }); - describe('when release.milestones is falsy', () => { - it('includes a "milestone" property in the returned result as an empty array', () => { - const release = {}; - - const expectedJson = { - milestones: [], + describe('when milestones contains full milestone objects', () => { + it('converts the milestone objects into titles', () => { + const release = { + milestones: [{ title: '13.2' }, { title: '13.3' }, '13.4'], }; + const expectedJson = { milestones: ['13.2', '13.3', '13.4'] }; + expect(releaseToApiJson(release)).toMatchObject(expectedJson); }); }); diff --git a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js index 3e11af9c9df..f99dcbffdff 100644 --- a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js +++ b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js @@ -1,10 +1,15 @@ +import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import component from '~/reports/codequality_report/components/codequality_issue_body.vue'; import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; describe('code quality issue body issue body', () => { let wrapper; + const findSeverityIcon = () => wrapper.findByTestId('codequality-severity-icon'); + const findGlIcon = () => wrapper.find(GlIcon); + const codequalityIssue = { name: 'rubygem-rest-client: session fixation vulnerability via Set-Cookie headers in 30x redirection responses', @@ -14,13 +19,15 @@ describe('code quality issue body issue body', () => { urlPath: '/Gemfile.lock#L22', }; - const mountWithStatus = initialStatus => { - wrapper = shallowMount(component, { - propsData: { - issue: codequalityIssue, - status: initialStatus, - }, - }); + const createComponent = (initialStatus, issue = codequalityIssue) => { + wrapper = extendedWrapper( + shallowMount(component, { + propsData: { + issue, + status: initialStatus, + }, + }), + ); }; afterEach(() => { @@ -28,17 +35,43 @@ describe('code quality issue body issue body', () => { wrapper = null; }); + describe('severity rating', () => { + it.each` + severity | iconClass | iconName + ${'info'} | ${'text-primary-400'} | ${'severity-info'} + ${'minor'} | ${'text-warning-200'} | ${'severity-low'} + ${'major'} | ${'text-warning-400'} | ${'severity-medium'} + ${'critical'} | ${'text-danger-600'} | ${'severity-high'} + ${'blocker'} | ${'text-danger-800'} | ${'severity-critical'} + ${'unknown'} | ${'text-secondary-400'} | ${'severity-unknown'} + ${'invalid'} | ${'text-secondary-400'} | ${'severity-unknown'} + `( + 'renders correct icon for "$severity" severity rating', + ({ severity, iconClass, iconName }) => { + createComponent(STATUS_FAILED, { + ...codequalityIssue, + severity, + }); + const icon = findGlIcon(); + + expect(findSeverityIcon().classes()).toContain(iconClass); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe(iconName); + }, + ); + }); + describe('with success', () => { it('renders fixed label', () => { - mountWithStatus(STATUS_SUCCESS); + createComponent(STATUS_SUCCESS); expect(wrapper.text()).toContain('Fixed'); }); }); describe('without success', () => { - it('renders fixed label', () => { - mountWithStatus(STATUS_FAILED); + it('does not render fixed label', () => { + createComponent(STATUS_FAILED); expect(wrapper.text()).not.toContain('Fixed'); }); @@ -46,7 +79,7 @@ describe('code quality issue body issue body', () => { describe('name', () => { it('renders name', () => { - mountWithStatus(STATUS_NEUTRAL); + createComponent(STATUS_NEUTRAL); expect(wrapper.text()).toContain(codequalityIssue.name); }); @@ -54,7 +87,7 @@ describe('code quality issue body issue body', () => { describe('path', () => { it('renders the report-link path using the correct code quality issue', () => { - mountWithStatus(STATUS_NEUTRAL); + createComponent(STATUS_NEUTRAL); expect(wrapper.find('report-link-stub').props('issue')).toBe(codequalityIssue); }); diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/components/grouped_test_reports_app_spec.js index 556904b7da5..ae2718db17f 100644 --- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js @@ -1,11 +1,13 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { mockTracking } from 'helpers/tracking_helper'; import GroupedTestReportsApp from '~/reports/components/grouped_test_reports_app.vue'; import { getStoreConfig } from '~/reports/store'; import { failedReport } from '../mock_data/mock_data'; import successTestReports from '../mock_data/no_failures_report.json'; import newFailedTestReports from '../mock_data/new_failures_report.json'; +import recentFailuresTestReports from '../mock_data/recent_failures_report.json'; import newErrorsTestReports from '../mock_data/new_errors_report.json'; import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json'; import resolvedFailures from '../mock_data/resolved_failures.json'; @@ -20,7 +22,7 @@ describe('Grouped test reports app', () => { let wrapper; let mockStore; - const mountComponent = ({ props = { pipelinePath } } = {}) => { + const mountComponent = ({ props = { pipelinePath }, testFailureHistory = false } = {}) => { wrapper = mount(Component, { store: mockStore, localVue, @@ -29,6 +31,11 @@ describe('Grouped test reports app', () => { pipelinePath, ...props, }, + provide: { + glFeatures: { + testFailureHistory, + }, + }, }); }; @@ -39,6 +46,7 @@ describe('Grouped test reports app', () => { }; const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]'); + const findExpandButton = () => wrapper.find('[data-testid="report-section-expand-button"]'); const findFullTestReportLink = () => wrapper.find('[data-testid="group-test-reports-full-link"]'); const findSummaryDescription = () => wrapper.find('[data-testid="test-summary-row-description"]'); const findIssueDescription = () => wrapper.find('[data-testid="test-issue-body-description"]'); @@ -96,6 +104,35 @@ describe('Grouped test reports app', () => { }); }); + describe('`Expand` button', () => { + let trackingSpy; + + beforeEach(() => { + setReports(newFailedTestReports); + mountComponent(); + document.body.dataset.page = 'projects:merge_requests:show'; + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + }); + + it('tracks an event on click', () => { + findExpandButton().trigger('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'expand_test_report_widget', {}); + }); + + it('only tracks the first expansion', () => { + expect(trackingSpy).not.toHaveBeenCalled(); + + const button = findExpandButton(); + + button.trigger('click'); + button.trigger('click'); + button.trigger('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('with new failed result', () => { beforeEach(() => { setReports(newFailedTestReports); @@ -203,6 +240,77 @@ describe('Grouped test reports app', () => { }); }); + describe('recent failures counts', () => { + describe('with recent failures counts', () => { + beforeEach(() => { + setReports(recentFailuresTestReports); + }); + + describe('with feature flag enabled', () => { + beforeEach(() => { + mountComponent({ testFailureHistory: true }); + }); + + it('renders the recently failed tests summary', () => { + expect(findHeader().text()).toContain( + '2 out of 3 failed tests have failed more than once in the last 14 days', + ); + }); + + it('renders the recently failed count on the test suite', () => { + expect(findSummaryDescription().text()).toContain( + '1 out of 2 failed tests has failed more than once in the last 14 days', + ); + }); + + it('renders the recent failures count on the test case', () => { + expect(findIssueDescription().text()).toContain('Failed 8 times in the last 14 days'); + }); + }); + + describe('with feature flag disabled', () => { + beforeEach(() => { + mountComponent({ testFailureHistory: false }); + }); + + it('does not render the recently failed tests summary', () => { + expect(findHeader().text()).not.toContain('failed more than once in the last 14 days'); + }); + + it('does not render the recently failed count on the test suite', () => { + expect(findSummaryDescription().text()).not.toContain( + 'failed more than once in the last 14 days', + ); + }); + + it('renders the recent failures count on the test case', () => { + expect(findIssueDescription().text()).not.toContain('in the last 14 days'); + }); + }); + }); + + describe('without recent failures counts', () => { + beforeEach(() => { + setReports(mixedResultsTestReports); + mountComponent(); + }); + + it('does not render the recently failed tests summary', () => { + expect(findHeader().text()).not.toContain('failed more than once in the last 14 days'); + }); + + it('does not render the recently failed count on the test suite', () => { + expect(findSummaryDescription().text()).not.toContain( + 'failed more than once in the last 14 days', + ); + }); + + it('does not render the recent failures count on the test case', () => { + expect(findIssueDescription().text()).not.toContain('in the last 14 days'); + }); + }); + }); + describe('with a report that failed to load', () => { beforeEach(() => { setReports(failedReport); diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js index a620b5d9afc..2d228313a9b 100644 --- a/spec/frontend/reports/components/report_section_spec.js +++ b/spec/frontend/reports/components/report_section_spec.js @@ -244,7 +244,7 @@ describe('Report section', () => { hasIssues: true, }, slots: { - actionButtons: ['Action!'], + 'action-buttons': ['Action!'], }, }); }); diff --git a/spec/frontend/reports/mock_data/recent_failures_report.json b/spec/frontend/reports/mock_data/recent_failures_report.json new file mode 100644 index 00000000000..a47bc30a8e5 --- /dev/null +++ b/spec/frontend/reports/mock_data/recent_failures_report.json @@ -0,0 +1,46 @@ +{ + "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 3, "recentlyFailed": 2 }, + "suites": [ + { + "name": "rspec:pg", + "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 2, "recentlyFailed": 1 }, + "new_failures": [ + { + "result": "failure", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.009411, + "system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'", + "recent_failures": 8 + }, + { + "result": "failure", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000162, + "system_output": "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'" + } + ], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "java ant", + "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 1, "recentlyFailed": 1 }, + "new_failures": [ + { + "result": "failure", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000562, + "recent_failures": 3 + } + ], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +} diff --git a/spec/frontend/reports/store/mutations_spec.js b/spec/frontend/reports/store/mutations_spec.js index 9446cd454ab..82a399c876d 100644 --- a/spec/frontend/reports/store/mutations_spec.js +++ b/spec/frontend/reports/store/mutations_spec.js @@ -46,6 +46,7 @@ describe('Reports Store Mutations', () => { name: 'StringHelper#concatenate when a is git and b is lab returns summary', execution_time: 0.0092435, system_output: "Failure/Error: is_expected.to eq('gitlab')", + recent_failures: 4, }, ], resolved_failures: [ @@ -82,6 +83,7 @@ describe('Reports Store Mutations', () => { expect(stateCopy.summary.total).toEqual(mockedResponse.summary.total); expect(stateCopy.summary.resolved).toEqual(mockedResponse.summary.resolved); expect(stateCopy.summary.failed).toEqual(mockedResponse.summary.failed); + expect(stateCopy.summary.recentlyFailed).toEqual(1); }); it('should set reports', () => { diff --git a/spec/frontend/reports/store/utils_spec.js b/spec/frontend/reports/store/utils_spec.js index 9ae456658dc..8977268115e 100644 --- a/spec/frontend/reports/store/utils_spec.js +++ b/spec/frontend/reports/store/utils_spec.js @@ -168,6 +168,54 @@ describe('Reports store utils', () => { }); }); + describe('recentFailuresTextBuilder', () => { + it.each` + recentlyFailed | failed | expected + ${0} | ${1} | ${''} + ${1} | ${1} | ${'1 out of 1 failed test has failed more than once in the last 14 days'} + ${1} | ${2} | ${'1 out of 2 failed tests has failed more than once in the last 14 days'} + ${2} | ${3} | ${'2 out of 3 failed tests have failed more than once in the last 14 days'} + `( + 'should render summary for $recentlyFailed out of $failed failures', + ({ recentlyFailed, failed, expected }) => { + const result = utils.recentFailuresTextBuilder({ recentlyFailed, failed }); + + expect(result).toBe(expected); + }, + ); + }); + + describe('countRecentlyFailedTests', () => { + it('counts tests with more than one recent failure in a report', () => { + const report = { + new_failures: [{ recent_failures: 2 }], + existing_failures: [{ recent_failures: 1 }], + resolved_failures: [{ recent_failures: 20 }, { recent_failures: 5 }], + }; + const result = utils.countRecentlyFailedTests(report); + + expect(result).toBe(3); + }); + + it('counts tests with more than one recent failure in an array of reports', () => { + const reports = [ + { + new_failures: [{ recent_failures: 2 }], + existing_failures: [{ recent_failures: 20 }, { recent_failures: 5 }], + resolved_failures: [{ recent_failures: 2 }], + }, + { + new_failures: [{ recent_failures: 8 }, { recent_failures: 14 }], + existing_failures: [{ recent_failures: 1 }], + resolved_failures: [{ recent_failures: 7 }, { recent_failures: 5 }], + }, + ]; + const result = utils.countRecentlyFailedTests(reports); + + expect(result).toBe(8); + }); + }); + describe('statusIcon', () => { describe('with failed status', () => { it('returns ICON_WARNING', () => { diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index aaa8bf168f2..be4f8a688e0 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -40,7 +40,6 @@ exports[`Repository last commit component renders commit widget 1`] = ` > Test - </gl-link-stub> authored @@ -147,7 +146,6 @@ exports[`Repository last commit component renders the signature HTML as returned > Test - </gl-link-stub> authored diff --git a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap index 23c06dc5e68..e2ccc07d0f2 100644 --- a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap +++ b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap @@ -29,6 +29,7 @@ exports[`Repository file preview component renders file HTML 1`] = ` <div class="blob-viewer" data-qa-selector="blob_viewer_content" + itemprop="about" > <div> <div diff --git a/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js b/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js deleted file mode 100644 index 4a6b5cebe1c..00000000000 --- a/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js +++ /dev/null @@ -1,196 +0,0 @@ -import Vuex from 'vuex'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import * as urlUtils from '~/lib/utils/url_utility'; -import initStore from '~/search/store'; -import DropdownFilter from '~/search/dropdown_filter/components/dropdown_filter.vue'; -import stateFilterData from '~/search/dropdown_filter/constants/state_filter_data'; -import confidentialFilterData from '~/search/dropdown_filter/constants/confidential_filter_data'; -import { MOCK_QUERY } from '../mock_data'; - -jest.mock('~/lib/utils/url_utility', () => ({ - visitUrl: jest.fn(), - setUrlParams: jest.fn(), -})); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('DropdownFilter', () => { - let wrapper; - let store; - - const createStore = options => { - store = initStore({ query: MOCK_QUERY, ...options }); - }; - - const createComponent = (props = { filterData: stateFilterData }) => { - wrapper = shallowMount(DropdownFilter, { - localVue, - store, - propsData: { - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - store = null; - }); - - const findGlDropdown = () => wrapper.find(GlDropdown); - const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); - const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text()); - const firstDropDownItem = () => findGlDropdownItems().at(0); - - describe('StatusFilter', () => { - describe('template', () => { - describe.each` - scope | showDropdown - ${'issues'} | ${true} - ${'merge_requests'} | ${true} - ${'projects'} | ${false} - ${'milestones'} | ${false} - ${'users'} | ${false} - ${'notes'} | ${false} - ${'wiki_blobs'} | ${false} - ${'blobs'} | ${false} - `(`dropdown`, ({ scope, showDropdown }) => { - beforeEach(() => { - createStore({ query: { ...MOCK_QUERY, scope } }); - createComponent(); - }); - - it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => { - expect(findGlDropdown().exists()).toBe(showDropdown); - }); - }); - - describe.each` - initialFilter | label - ${stateFilterData.filters.ANY.value} | ${`Any ${stateFilterData.header}`} - ${stateFilterData.filters.OPEN.value} | ${stateFilterData.filters.OPEN.label} - ${stateFilterData.filters.CLOSED.value} | ${stateFilterData.filters.CLOSED.label} - `(`filter text`, ({ initialFilter, label }) => { - describe(`when initialFilter is ${initialFilter}`, () => { - beforeEach(() => { - createStore({ query: { ...MOCK_QUERY, [stateFilterData.filterParam]: initialFilter } }); - createComponent(); - }); - - it(`sets dropdown label to ${label}`, () => { - expect(findGlDropdown().attributes('text')).toBe(label); - }); - }); - }); - }); - - describe('Filter options', () => { - beforeEach(() => { - createStore(); - createComponent(); - }); - - it('renders a dropdown item for each filterOption', () => { - expect(findDropdownItemsText()).toStrictEqual( - stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(v => { - return v.label; - }), - ); - }); - - it('clicking a dropdown item calls setUrlParams', () => { - const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value; - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ - [stateFilterData.filterParam]: filter, - }); - }); - - it('clicking a dropdown item calls visitUrl', () => { - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.visitUrl).toHaveBeenCalled(); - }); - }); - }); - - describe('ConfidentialFilter', () => { - describe('template', () => { - describe.each` - scope | showDropdown - ${'issues'} | ${true} - ${'merge_requests'} | ${false} - ${'projects'} | ${false} - ${'milestones'} | ${false} - ${'users'} | ${false} - ${'notes'} | ${false} - ${'wiki_blobs'} | ${false} - ${'blobs'} | ${false} - `(`dropdown`, ({ scope, showDropdown }) => { - beforeEach(() => { - createStore({ query: { ...MOCK_QUERY, scope } }); - createComponent({ filterData: confidentialFilterData }); - }); - - it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => { - expect(findGlDropdown().exists()).toBe(showDropdown); - }); - }); - - describe.each` - initialFilter | label - ${confidentialFilterData.filters.ANY.value} | ${`Any ${confidentialFilterData.header}`} - ${confidentialFilterData.filters.CONFIDENTIAL.value} | ${confidentialFilterData.filters.CONFIDENTIAL.label} - ${confidentialFilterData.filters.NOT_CONFIDENTIAL.value} | ${confidentialFilterData.filters.NOT_CONFIDENTIAL.label} - `(`filter text`, ({ initialFilter, label }) => { - describe(`when initialFilter is ${initialFilter}`, () => { - beforeEach(() => { - createStore({ - query: { ...MOCK_QUERY, [confidentialFilterData.filterParam]: initialFilter }, - }); - createComponent({ filterData: confidentialFilterData }); - }); - - it(`sets dropdown label to ${label}`, () => { - expect(findGlDropdown().attributes('text')).toBe(label); - }); - }); - }); - }); - }); - - describe('Filter options', () => { - beforeEach(() => { - createStore(); - createComponent({ filterData: confidentialFilterData }); - }); - - it('renders a dropdown item for each filterOption', () => { - expect(findDropdownItemsText()).toStrictEqual( - confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(v => { - return v.label; - }), - ); - }); - - it('clicking a dropdown item calls setUrlParams', () => { - const filter = - confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value; - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ - [confidentialFilterData.filterParam]: filter, - }); - }); - - it('clicking a dropdown item calls visitUrl', () => { - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.visitUrl).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/search/dropdown_filter/mock_data.js b/spec/frontend/search/dropdown_filter/mock_data.js deleted file mode 100644 index f11ab3d9951..00000000000 --- a/spec/frontend/search/dropdown_filter/mock_data.js +++ /dev/null @@ -1,5 +0,0 @@ -export const MOCK_QUERY = { - scope: 'issues', - state: 'all', - confidential: null, -}; diff --git a/spec/frontend/search/group_filter/components/group_filter_spec.js b/spec/frontend/search/group_filter/components/group_filter_spec.js new file mode 100644 index 00000000000..fd3a4449f41 --- /dev/null +++ b/spec/frontend/search/group_filter/components/group_filter_spec.js @@ -0,0 +1,172 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import * as urlUtils from '~/lib/utils/url_utility'; +import GroupFilter from '~/search/group_filter/components/group_filter.vue'; +import { GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM, ANY_GROUP } from '~/search/group_filter/constants'; +import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + setUrlParams: jest.fn(), +})); + +describe('Global Search Group Filter', () => { + let wrapper; + + const actionSpies = { + fetchGroups: jest.fn(), + }; + + const defaultProps = { + initialGroup: null, + }; + + const createComponent = (initialState, props = {}, mountFn = shallowMount) => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + actions: actionSpies, + }); + + wrapper = mountFn(GroupFilter, { + localVue, + store, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlDropdownSearch = () => findGlDropdown().find(GlSearchBoxByType); + const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text'); + const findDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); + const findDropdownItemsText = () => findDropdownItems().wrappers.map(w => w.text()); + const findAnyDropdownItem = () => findDropdownItems().at(0); + const findFirstGroupDropdownItem = () => findDropdownItems().at(1); + const findLoader = () => wrapper.find(GlSkeletonLoader); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlDropdown', () => { + expect(findGlDropdown().exists()).toBe(true); + }); + + describe('findGlDropdownSearch', () => { + it('renders always', () => { + expect(findGlDropdownSearch().exists()).toBe(true); + }); + + it('has debounce prop', () => { + expect(findGlDropdownSearch().attributes('debounce')).toBe('500'); + }); + + describe('onSearch', () => { + const groupSearch = 'test search'; + + beforeEach(() => { + findGlDropdownSearch().vm.$emit('input', groupSearch); + }); + + it('calls fetchGroups when input event is fired from GlSearchBoxByType', () => { + expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), groupSearch); + }); + }); + }); + + describe('findDropdownItems', () => { + describe('when fetchingGroups is false', () => { + beforeEach(() => { + createComponent({ groups: MOCK_GROUPS }); + }); + + it('does not render loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders an instance for each namespace', () => { + const groupsIncludingAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name)); + expect(findDropdownItemsText()).toStrictEqual(groupsIncludingAny); + }); + }); + + describe('when fetchingGroups is true', () => { + beforeEach(() => { + createComponent({ fetchingGroups: true, groups: MOCK_GROUPS }); + }); + + it('does render loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('renders only Any in dropdown', () => { + expect(findDropdownItemsText()).toStrictEqual(['Any']); + }); + }); + }); + + describe('Dropdown Text', () => { + describe('when initialGroup is null', () => { + beforeEach(() => { + createComponent({}, {}, mount); + }); + + it('sets dropdown text to Any', () => { + expect(findDropdownText().text()).toBe(ANY_GROUP.name); + }); + }); + + describe('initialGroup is set', () => { + beforeEach(() => { + createComponent({}, { initialGroup: MOCK_GROUP }, mount); + }); + + it('sets dropdown text to group name', () => { + expect(findDropdownText().text()).toBe(MOCK_GROUP.name); + }); + }); + }); + }); + + describe('actions', () => { + beforeEach(() => { + createComponent({ groups: MOCK_GROUPS }); + }); + + it('clicking "Any" dropdown item calls setUrlParams with group id null, project id null,and visitUrl', () => { + findAnyDropdownItem().vm.$emit('click'); + + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ + [GROUP_QUERY_PARAM]: ANY_GROUP.id, + [PROJECT_QUERY_PARAM]: null, + }); + expect(urlUtils.visitUrl).toHaveBeenCalled(); + }); + + it('clicking group dropdown item calls setUrlParams with group id, project id null, and visitUrl', () => { + findFirstGroupDropdownItem().vm.$emit('click'); + + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ + [GROUP_QUERY_PARAM]: MOCK_GROUPS[0].id, + [PROJECT_QUERY_PARAM]: null, + }); + expect(urlUtils.visitUrl).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/pages/search/show/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js index 4083a65df75..112e6f5124f 100644 --- a/spec/frontend/pages/search/show/highlight_blob_search_result_spec.js +++ b/spec/frontend/search/highlight_blob_search_result_spec.js @@ -1,8 +1,8 @@ -import setHighlightClass from '~/pages/search/show/highlight_blob_search_result'; +import setHighlightClass from '~/search/highlight_blob_search_result'; const fixture = 'search/blob_search_result.html'; -describe('pages/search/show/highlight_blob_search_result', () => { +describe('search/highlight_blob_search_result', () => { preloadFixtures(fixture); beforeEach(() => loadFixtures(fixture)); @@ -10,6 +10,6 @@ describe('pages/search/show/highlight_blob_search_result', () => { it('highlights lines with search term occurrence', () => { setHighlightClass(); - expect(document.querySelectorAll('.blob-result .hll').length).toBe(11); + expect(document.querySelectorAll('.blob-result .hll').length).toBe(4); }); }); diff --git a/spec/frontend/search/index_spec.js b/spec/frontend/search/index_spec.js new file mode 100644 index 00000000000..8a86cc4c52a --- /dev/null +++ b/spec/frontend/search/index_spec.js @@ -0,0 +1,47 @@ +import { initSearchApp } from '~/search'; +import createStore from '~/search/store'; + +jest.mock('~/search/store'); +jest.mock('~/search/sidebar'); +jest.mock('~/search/group_filter'); + +describe('initSearchApp', () => { + let defaultLocation; + + const setUrl = query => { + window.location.href = `https://localhost:3000/search${query}`; + window.location.search = query; + }; + + beforeEach(() => { + defaultLocation = window.location; + Object.defineProperty(window, 'location', { + writable: true, + value: { href: '', search: '' }, + }); + }); + + afterEach(() => { + window.location = defaultLocation; + }); + + describe.each` + search | decodedSearch + ${'test'} | ${'test'} + ${'%2520'} | ${'%20'} + ${'test%2Bthis%2Bstuff'} | ${'test+this+stuff'} + ${'test+this+stuff'} | ${'test this stuff'} + ${'test+%2B+this+%2B+stuff'} | ${'test + this + stuff'} + ${'test%2B+%2Bthis%2B+%2Bstuff'} | ${'test+ +this+ +stuff'} + ${'test+%2520+this+%2520+stuff'} | ${'test %20 this %20 stuff'} + `('parameter decoding', ({ search, decodedSearch }) => { + beforeEach(() => { + setUrl(`?search=${search}`); + initSearchApp(); + }); + + it(`decodes ${search} to ${decodedSearch}`, () => { + expect(createStore).toHaveBeenCalledWith({ query: { search: decodedSearch } }); + }); + }); +}); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js new file mode 100644 index 00000000000..68fc432881a --- /dev/null +++ b/spec/frontend/search/mock_data.js @@ -0,0 +1,24 @@ +export const MOCK_QUERY = { + scope: 'issues', + state: 'all', + confidential: null, +}; + +export const MOCK_GROUP = { + name: 'test group', + full_name: 'full name test group', + id: 'test_1', +}; + +export const MOCK_GROUPS = [ + { + name: 'test group', + full_name: 'full name test group', + id: 'test_1', + }, + { + name: 'test group 2', + full_name: 'full name test group 2', + id: 'test_2', + }, +]; diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js new file mode 100644 index 00000000000..d2c0081080c --- /dev/null +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -0,0 +1,103 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlButton, GlLink } from '@gitlab/ui'; +import { MOCK_QUERY } from 'jest/search/mock_data'; +import GlobalSearchSidebar from '~/search/sidebar/components/app.vue'; +import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue'; +import StatusFilter from '~/search/sidebar/components/status_filter.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('GlobalSearchSidebar', () => { + let wrapper; + + const actionSpies = { + applyQuery: jest.fn(), + resetQuery: jest.fn(), + }; + + const createComponent = initialState => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + actions: actionSpies, + }); + + wrapper = shallowMount(GlobalSearchSidebar, { + localVue, + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findSidebarForm = () => wrapper.find('form'); + const findStatusFilter = () => wrapper.find(StatusFilter); + const findConfidentialityFilter = () => wrapper.find(ConfidentialityFilter); + const findApplyButton = () => wrapper.find(GlButton); + const findResetLinkButton = () => wrapper.find(GlLink); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders StatusFilter always', () => { + expect(findStatusFilter().exists()).toBe(true); + }); + + it('renders ConfidentialityFilter always', () => { + expect(findConfidentialityFilter().exists()).toBe(true); + }); + + it('renders ApplyButton always', () => { + expect(findApplyButton().exists()).toBe(true); + }); + }); + + describe('ResetLinkButton', () => { + describe('with no filter selected', () => { + beforeEach(() => { + createComponent({ query: {} }); + }); + + it('does not render', () => { + expect(findResetLinkButton().exists()).toBe(false); + }); + }); + + describe('with filter selected', () => { + beforeEach(() => { + createComponent(); + }); + + it('does render when a filter selected', () => { + expect(findResetLinkButton().exists()).toBe(true); + }); + }); + }); + + describe('actions', () => { + beforeEach(() => { + createComponent(); + }); + + it('clicking ApplyButton calls applyQuery', () => { + findSidebarForm().trigger('submit'); + + expect(actionSpies.applyQuery).toHaveBeenCalled(); + }); + + it('clicking ResetLinkButton calls resetQuery', () => { + findResetLinkButton().vm.$emit('click'); + + expect(actionSpies.resetQuery).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js new file mode 100644 index 00000000000..68d20b2480e --- /dev/null +++ b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js @@ -0,0 +1,65 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { MOCK_QUERY } from 'jest/search/mock_data'; +import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue'; +import RadioFilter from '~/search/sidebar/components/radio_filter.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ConfidentialityFilter', () => { + let wrapper; + + const actionSpies = { + applyQuery: jest.fn(), + resetQuery: jest.fn(), + }; + + const createComponent = initialState => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + actions: actionSpies, + }); + + wrapper = shallowMount(ConfidentialityFilter, { + localVue, + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findRadioFilter = () => wrapper.find(RadioFilter); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + describe.each` + scope | showFilter + ${'issues'} | ${true} + ${'merge_requests'} | ${false} + ${'projects'} | ${false} + ${'milestones'} | ${false} + ${'users'} | ${false} + ${'notes'} | ${false} + ${'wiki_blobs'} | ${false} + ${'blobs'} | ${false} + `(`dropdown`, ({ scope, showFilter }) => { + beforeEach(() => { + createComponent({ query: { scope } }); + }); + + it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => { + expect(findRadioFilter().exists()).toBe(showFilter); + }); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/radio_filter_spec.js b/spec/frontend/search/sidebar/components/radio_filter_spec.js new file mode 100644 index 00000000000..31a4a8859ee --- /dev/null +++ b/spec/frontend/search/sidebar/components/radio_filter_spec.js @@ -0,0 +1,111 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; +import { MOCK_QUERY } from 'jest/search/mock_data'; +import RadioFilter from '~/search/sidebar/components/radio_filter.vue'; +import { stateFilterData } from '~/search/sidebar/constants/state_filter_data'; +import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RadioFilter', () => { + let wrapper; + + const actionSpies = { + setQuery: jest.fn(), + }; + + const defaultProps = { + filterData: stateFilterData, + }; + + const createComponent = (initialState, props = {}) => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + actions: actionSpies, + }); + + wrapper = shallowMount(RadioFilter, { + localVue, + store, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlRadioButtonGroup = () => wrapper.find(GlFormRadioGroup); + const findGlRadioButtons = () => findGlRadioButtonGroup().findAll(GlFormRadio); + const findGlRadioButtonsText = () => findGlRadioButtons().wrappers.map(w => w.text()); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlRadioButtonGroup always', () => { + expect(findGlRadioButtonGroup().exists()).toBe(true); + }); + + describe('Radio Buttons', () => { + describe('Status Filter', () => { + it('renders a radio button for each filterOption', () => { + expect(findGlRadioButtonsText()).toStrictEqual( + stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(f => { + return f.value === stateFilterData.filters.ANY.value + ? `Any ${stateFilterData.header.toLowerCase()}` + : f.label; + }), + ); + }); + + it('clicking a radio button item calls setQuery', () => { + const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value; + findGlRadioButtonGroup().vm.$emit('input', filter); + + expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), { + key: stateFilterData.filterParam, + value: filter, + }); + }); + }); + + describe('Confidentiality Filter', () => { + beforeEach(() => { + createComponent({}, { filterData: confidentialFilterData }); + }); + + it('renders a radio button for each filterOption', () => { + expect(findGlRadioButtonsText()).toStrictEqual( + confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(f => { + return f.value === confidentialFilterData.filters.ANY.value + ? `Any ${confidentialFilterData.header.toLowerCase()}` + : f.label; + }), + ); + }); + + it('clicking a radio button item calls setQuery', () => { + const filter = + confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value; + findGlRadioButtonGroup().vm.$emit('input', filter); + + expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), { + key: confidentialFilterData.filterParam, + value: filter, + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/status_filter_spec.js b/spec/frontend/search/sidebar/components/status_filter_spec.js new file mode 100644 index 00000000000..188d47b38cd --- /dev/null +++ b/spec/frontend/search/sidebar/components/status_filter_spec.js @@ -0,0 +1,65 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { MOCK_QUERY } from 'jest/search/mock_data'; +import StatusFilter from '~/search/sidebar/components/status_filter.vue'; +import RadioFilter from '~/search/sidebar/components/radio_filter.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('StatusFilter', () => { + let wrapper; + + const actionSpies = { + applyQuery: jest.fn(), + resetQuery: jest.fn(), + }; + + const createComponent = initialState => { + const store = new Vuex.Store({ + state: { + query: MOCK_QUERY, + ...initialState, + }, + actions: actionSpies, + }); + + wrapper = shallowMount(StatusFilter, { + localVue, + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findRadioFilter = () => wrapper.find(RadioFilter); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + describe.each` + scope | showFilter + ${'issues'} | ${true} + ${'merge_requests'} | ${true} + ${'projects'} | ${false} + ${'milestones'} | ${false} + ${'users'} | ${false} + ${'notes'} | ${false} + ${'wiki_blobs'} | ${false} + ${'blobs'} | ${false} + `(`dropdown`, ({ scope, showFilter }) => { + beforeEach(() => { + createComponent({ query: { scope } }); + }); + + it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => { + expect(findRadioFilter().exists()).toBe(showFilter); + }); + }); + }); +}); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js new file mode 100644 index 00000000000..c8ea6167399 --- /dev/null +++ b/spec/frontend/search/store/actions_spec.js @@ -0,0 +1,90 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import * as actions from '~/search/store/actions'; +import * as types from '~/search/store/mutation_types'; +import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; +import state from '~/search/store/state'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import { MOCK_GROUPS } from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility', () => ({ + setUrlParams: jest.fn(), + visitUrl: jest.fn(), + joinPaths: jest.fn(), // For the axios specs +})); + +describe('Global Search Store Actions', () => { + let mock; + + const noCallback = () => {}; + const flashCallback = () => { + expect(createFlash).toHaveBeenCalledTimes(1); + createFlash.mockClear(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe.each` + action | axiosMock | type | mutationCalls | callback + ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${noCallback} + ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${flashCallback} + `(`axios calls`, ({ action, axiosMock, type, mutationCalls, callback }) => { + describe(action.name, () => { + describe(`on ${type}`, () => { + beforeEach(() => { + mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res); + }); + it(`should dispatch the correct mutations`, () => { + return testAction(action, null, state, mutationCalls, []).then(() => callback()); + }); + }); + }); + }); + + describe('setQuery', () => { + const payload = { key: 'key1', value: 'value1' }; + + it('calls the SET_QUERY mutation', done => { + testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done); + }); + }); + + describe('applyQuery', () => { + it('calls visitUrl and setParams with the state.query', () => { + testAction(actions.applyQuery, null, state, [], [], () => { + expect(setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null }); + expect(visitUrl).toHaveBeenCalled(); + }); + }); + }); + + describe('resetQuery', () => { + it('calls visitUrl and setParams with empty values', () => { + testAction(actions.resetQuery, null, state, [], [], () => { + expect(setUrlParams).toHaveBeenCalledWith({ + ...state.query, + page: null, + state: null, + confidential: null, + }); + expect(visitUrl).toHaveBeenCalled(); + }); + }); + }); +}); + +describe('setQuery', () => { + const payload = { key: 'key1', value: 'value1' }; + + it('calls the SET_QUERY mutation', done => { + testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done); + }); +}); diff --git a/spec/frontend/search/store/mutations_spec.js b/spec/frontend/search/store/mutations_spec.js new file mode 100644 index 00000000000..28d9646b97e --- /dev/null +++ b/spec/frontend/search/store/mutations_spec.js @@ -0,0 +1,48 @@ +import mutations from '~/search/store/mutations'; +import createState from '~/search/store/state'; +import * as types from '~/search/store/mutation_types'; +import { MOCK_QUERY, MOCK_GROUPS } from '../mock_data'; + +describe('Global Search Store Mutations', () => { + let state; + + beforeEach(() => { + state = createState({ query: MOCK_QUERY }); + }); + + describe('REQUEST_GROUPS', () => { + it('sets fetchingGroups to true', () => { + mutations[types.REQUEST_GROUPS](state); + + expect(state.fetchingGroups).toBe(true); + }); + }); + + describe('RECEIVE_GROUPS_SUCCESS', () => { + it('sets fetchingGroups to false and sets groups', () => { + mutations[types.RECEIVE_GROUPS_SUCCESS](state, MOCK_GROUPS); + + expect(state.fetchingGroups).toBe(false); + expect(state.groups).toBe(MOCK_GROUPS); + }); + }); + + describe('RECEIVE_GROUPS_ERROR', () => { + it('sets fetchingGroups to false and clears groups', () => { + mutations[types.RECEIVE_GROUPS_ERROR](state); + + expect(state.fetchingGroups).toBe(false); + expect(state.groups).toEqual([]); + }); + }); + + describe('SET_QUERY', () => { + const payload = { key: 'key1', value: 'value1' }; + + it('sets query key to value', () => { + mutations[types.SET_QUERY](state, payload); + + expect(state.query[payload.key]).toBe(payload.value); + }); + }); +}); diff --git a/spec/frontend/search_spec.js b/spec/frontend/search_spec.js index 1573365538c..cbbc2df6c78 100644 --- a/spec/frontend/search_spec.js +++ b/spec/frontend/search_spec.js @@ -1,10 +1,10 @@ import $ from 'jquery'; +import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; import Api from '~/api'; import Search from '~/pages/search/show/search'; -import setHighlightClass from '~/pages/search/show/highlight_blob_search_result'; jest.mock('~/api'); -jest.mock('~/pages/search/show/highlight_blob_search_result'); +jest.mock('ee_else_ce/search/highlight_blob_search_result'); describe('Search', () => { const fixturePath = 'search/show.html'; @@ -36,16 +36,6 @@ describe('Search', () => { new Search(); // eslint-disable-line no-new }); - it('requests groups from backend when filtering', () => { - jest.spyOn(Api, 'groups').mockImplementation(term => { - expect(term).toBe(searchTerm); - }); - - const inputElement = fillDropdownInput('.js-search-group-dropdown'); - - $(inputElement).trigger('input'); - }); - it('requests projects from backend when filtering', () => { jest.spyOn(Api, 'projects').mockImplementation(term => { expect(term).toBe(searchTerm); diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js new file mode 100644 index 00000000000..fad23aa05a4 --- /dev/null +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -0,0 +1,257 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal, GlFormCheckbox } from '@gitlab/ui'; +import { initEmojiMock } from 'helpers/emoji'; +import Api from '~/api'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import SetStatusModalWrapper, { + AVAILABILITY_STATUS, +} from '~/set_status_modal/set_status_modal_wrapper.vue'; + +jest.mock('~/api'); +jest.mock('~/flash'); + +describe('SetStatusModalWrapper', () => { + let wrapper; + let mockEmoji; + const $toast = { + show: jest.fn(), + }; + + const defaultEmoji = 'speech_balloon'; + const defaultMessage = "They're comin' in too fast!"; + + const defaultProps = { + currentEmoji: defaultEmoji, + currentMessage: defaultMessage, + defaultEmoji, + canSetUserAvailability: true, + }; + + const createComponent = (props = {}) => { + return shallowMount(SetStatusModalWrapper, { + propsData: { + ...defaultProps, + ...props, + }, + mocks: { + $toast, + }, + }); + }; + + const findModal = () => wrapper.find(GlModal); + const findFormField = field => wrapper.find(`[name="user[status][${field}]"]`); + const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button'); + const findNoEmojiPlaceholder = () => wrapper.find('.js-no-emoji-placeholder'); + const findToggleEmojiButton = () => wrapper.find('.js-toggle-emoji-menu'); + const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox); + + const initModal = ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => { + const modal = findModal(); + // mock internal emoji methods + wrapper.vm.showEmojiMenu = jest.fn(); + wrapper.vm.hideEmojiMenu = jest.fn(); + if (mockOnUpdateSuccess) wrapper.vm.onUpdateSuccess = jest.fn(); + if (mockOnUpdateFailure) wrapper.vm.onUpdateFail = jest.fn(); + + modal.vm.$emit('shown'); + return wrapper.vm.$nextTick(); + }; + + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent(); + return initModal(); + }); + + afterEach(() => { + wrapper.destroy(); + mockEmoji.restore(); + }); + + describe('with minimum props', () => { + it('sets the hidden status emoji field', () => { + const field = findFormField('emoji'); + expect(field.exists()).toBe(true); + expect(field.element.value).toBe(defaultEmoji); + }); + + it('sets the message field', () => { + const field = findFormField('message'); + expect(field.exists()).toBe(true); + expect(field.element.value).toBe(defaultMessage); + }); + + it('sets the availability field to false', () => { + const field = findAvailabilityCheckbox(); + expect(field.exists()).toBe(true); + expect(field.element.checked).toBeUndefined(); + }); + + it('has a clear status button', () => { + expect(findClearStatusButton().isVisible()).toBe(true); + }); + + it('clicking the toggle emoji button displays the emoji list', () => { + expect(wrapper.vm.showEmojiMenu).not.toHaveBeenCalled(); + findToggleEmojiButton().trigger('click'); + expect(wrapper.vm.showEmojiMenu).toHaveBeenCalled(); + }); + }); + + describe('with no currentMessage set', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent({ currentMessage: '' }); + return initModal(); + }); + + it('does not set the message field', () => { + expect(findFormField('message').element.value).toBe(''); + }); + + it('hides the clear status button', () => { + expect(findClearStatusButton().isVisible()).toBe(false); + }); + + it('shows the placeholder emoji', () => { + expect(findNoEmojiPlaceholder().isVisible()).toBe(true); + }); + }); + + describe('with no currentEmoji set', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent({ currentEmoji: '' }); + return initModal(); + }); + + it('does not set the hidden status emoji field', () => { + expect(findFormField('emoji').element.value).toBe(''); + }); + + it('hides the placeholder emoji', () => { + expect(findNoEmojiPlaceholder().isVisible()).toBe(false); + }); + + describe('with no currentMessage set', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); + return initModal(); + }); + + it('shows the placeholder emoji', () => { + expect(findNoEmojiPlaceholder().isVisible()).toBe(true); + }); + }); + }); + + describe('update status', () => { + describe('succeeds', () => { + beforeEach(() => { + jest.spyOn(Api, 'postUserStatus').mockResolvedValue(); + }); + + it('clicking "removeStatus" clears the emoji and message fields', async () => { + findModal().vm.$emit('cancel'); + await wrapper.vm.$nextTick(); + + expect(findFormField('message').element.value).toBe(''); + expect(findFormField('emoji').element.value).toBe(''); + }); + + it('clicking "setStatus" submits the user status', async () => { + findModal().vm.$emit('ok'); + await wrapper.vm.$nextTick(); + + // set the availability status + findAvailabilityCheckbox().vm.$emit('input', true); + + findModal().vm.$emit('ok'); + await wrapper.vm.$nextTick(); + + const commonParams = { emoji: defaultEmoji, message: defaultMessage }; + + expect(Api.postUserStatus).toHaveBeenCalledTimes(2); + expect(Api.postUserStatus).toHaveBeenNthCalledWith(1, { + availability: AVAILABILITY_STATUS.NOT_SET, + ...commonParams, + }); + expect(Api.postUserStatus).toHaveBeenNthCalledWith(2, { + availability: AVAILABILITY_STATUS.BUSY, + ...commonParams, + }); + }); + + it('calls the "onUpdateSuccess" handler', async () => { + findModal().vm.$emit('ok'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.onUpdateSuccess).toHaveBeenCalled(); + }); + }); + + describe('success message', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); + jest.spyOn(Api, 'postUserStatus').mockResolvedValue(); + return initModal({ mockOnUpdateSuccess: false }); + }); + + it('displays a toast success message', async () => { + findModal().vm.$emit('ok'); + await wrapper.vm.$nextTick(); + + expect($toast.show).toHaveBeenCalledWith('Status updated', { + position: 'top-center', + type: 'success', + }); + }); + }); + + describe('with errors', () => { + beforeEach(() => { + jest.spyOn(Api, 'postUserStatus').mockRejectedValue(); + }); + + it('calls the "onUpdateFail" handler', async () => { + findModal().vm.$emit('ok'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.onUpdateFail).toHaveBeenCalled(); + }); + }); + + describe('error message', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); + jest.spyOn(Api, 'postUserStatus').mockRejectedValue(); + return initModal({ mockOnUpdateFailure: false }); + }); + + it('flashes an error message', async () => { + findModal().vm.$emit('ok'); + await wrapper.vm.$nextTick(); + + expect(createFlash).toHaveBeenCalledWith( + "Sorry, we weren't able to set your status. Please try again later.", + ); + }); + }); + }); + + describe('with canSetUserAvailability=false', () => { + beforeEach(async () => { + mockEmoji = await initEmojiMock(); + wrapper = createComponent({ canSetUserAvailability: false }); + return initModal(); + }); + + it('hides the set availability checkbox', () => { + expect(findAvailabilityCheckbox().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/set_status_modal/user_availability_status_spec.js b/spec/frontend/set_status_modal/user_availability_status_spec.js new file mode 100644 index 00000000000..95ca0251ce0 --- /dev/null +++ b/spec/frontend/set_status_modal/user_availability_status_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue'; +import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; + +describe('UserAvailabilityStatus', () => { + let wrapper; + + const createComponent = (props = {}) => { + return shallowMount(UserAvailabilityStatus, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with availability status', () => { + it(`set to ${AVAILABILITY_STATUS.BUSY}`, () => { + wrapper = createComponent({ availability: AVAILABILITY_STATUS.BUSY }); + expect(wrapper.text()).toContain('(Busy)'); + }); + + it(`set to ${AVAILABILITY_STATUS.NOT_SET}`, () => { + wrapper = createComponent({ availability: AVAILABILITY_STATUS.NOT_SET }); + expect(wrapper.html()).toBe(''); + }); + }); +}); diff --git a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap index 42012841f0b..6640c0844e2 100644 --- a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap @@ -27,7 +27,7 @@ exports[`SidebarTodo template renders component container element with proper da </span> <gl-loading-icon-stub - color="orange" + color="dark" inline="true" label="Loading" size="sm" diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index 5307be0bf58..bcd2c14f2fa 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -1,277 +1,226 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; +import { createMockDirective } from 'helpers/vue_mock_directive'; +import { mount } from '@vue/test-utils'; import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; describe('Issuable Time Tracker', () => { - let initialData; - let vm; - - const initTimeTrackingComponent = ({ - timeEstimate, - timeSpent, - timeEstimateHumanReadable, - timeSpentHumanReadable, - limitToHours, - }) => { - setFixtures(` - <div> - <div id="mock-container"></div> - </div> - `); - - initialData = { - timeEstimate, - timeSpent, - humanTimeEstimate: timeEstimateHumanReadable, - humanTimeSpent: timeSpentHumanReadable, - limitToHours: Boolean(limitToHours), - rootPath: '/', - }; - - const TimeTrackingComponent = Vue.extend({ - ...TimeTracker, - components: { - ...TimeTracker.components, - transition: { - // disable animations - render(h) { - return h('div', this.$slots.default); - }, - }, - }, - }); - vm = mountComponent(TimeTrackingComponent, initialData, '#mock-container'); + let wrapper; + + const findByTestId = testId => wrapper.find(`[data-testid=${testId}]`); + const findComparisonMeter = () => findByTestId('compareMeter').attributes('title'); + const findCollapsedState = () => findByTestId('collapsedState'); + const findTimeRemainingProgress = () => findByTestId('timeRemainingProgress'); + + const defaultProps = { + timeEstimate: 10_000, // 2h 46m + timeSpent: 5_000, // 1h 23m + humanTimeEstimate: '2h 46m', + humanTimeSpent: '1h 23m', + limitToHours: false, }; + const mountComponent = ({ props = {} } = {}) => + mount(TimeTracker, { + propsData: { ...defaultProps, ...props }, + directives: { GlTooltip: createMockDirective() }, + }); + afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('Initialization', () => { beforeEach(() => { - initTimeTrackingComponent({ - timeEstimate: 10000, // 2h 46m - timeSpent: 5000, // 1h 23m - timeEstimateHumanReadable: '2h 46m', - timeSpentHumanReadable: '1h 23m', - }); + wrapper = mountComponent(); }); it('should return something defined', () => { - expect(vm).toBeDefined(); + expect(wrapper).toBeDefined(); }); - it('should correctly set timeEstimate', done => { - Vue.nextTick(() => { - expect(vm.timeEstimate).toBe(initialData.timeEstimate); - done(); - }); + it('should correctly render timeEstimate', () => { + expect(findByTestId('timeTrackingComparisonPane').html()).toContain( + defaultProps.humanTimeEstimate, + ); }); - it('should correctly set time_spent', done => { - Vue.nextTick(() => { - expect(vm.timeSpent).toBe(initialData.timeSpent); - done(); - }); + it('should correctly render time_spent', () => { + expect(findByTestId('timeTrackingComparisonPane').html()).toContain( + defaultProps.humanTimeSpent, + ); }); }); - describe('Content Display', () => { - describe('Panes', () => { - describe('Comparison pane', () => { - beforeEach(() => { - initTimeTrackingComponent({ - timeEstimate: 100000, // 1d 3h - timeSpent: 5000, // 1h 23m - timeEstimateHumanReadable: '1d 3h', - timeSpentHumanReadable: '1h 23m', - }); + describe('Content panes', () => { + describe('Collapsed state', () => { + it('should render "time-tracking-collapsed-state" by default when "showCollapsed" prop is not specified', () => { + wrapper = mountComponent(); + + expect(findCollapsedState().exists()).toBe(true); + }); + + it('should not render "time-tracking-collapsed-state" when "showCollapsed" is false', () => { + wrapper = mountComponent({ + props: { + showCollapsed: false, + }, }); - it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', done => { - Vue.nextTick(() => { - expect(vm.showComparisonState).toBe(true); - const $comparisonPane = vm.$el.querySelector('.time-tracking-comparison-pane'); + expect(findCollapsedState().exists()).toBe(false); + }); + }); - expect($comparisonPane).toBeVisible(); - done(); - }); + describe('Comparison pane', () => { + beforeEach(() => { + wrapper = mountComponent({ + props: { + timeEstimate: 100_000, // 1d 3h + timeSpent: 5_000, // 1h 23m + humanTimeEstimate: '1d 3h', + humanTimeSpent: '1h 23m', + }, }); + }); - it('should show full times when the sidebar is collapsed', done => { - Vue.nextTick(() => { - const timeTrackingText = vm.$el.querySelector('.time-tracking-collapsed-summary span') - .textContent; + it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', () => { + const pane = findByTestId('timeTrackingComparisonPane'); + expect(pane.exists()).toBe(true); + expect(pane.isVisible()).toBe(true); + }); - expect(timeTrackingText.trim()).toBe('1h 23m / 1d 3h'); - done(); - }); + it('should show full times when the sidebar is collapsed', () => { + expect(findCollapsedState().text()).toBe('1h 23m / 1d 3h'); + }); + + describe('Remaining meter', () => { + it('should display the remaining meter with the correct width', () => { + expect(findTimeRemainingProgress().attributes('value')).toBe('5'); }); - describe('Remaining meter', () => { - it('should display the remaining meter with the correct width', done => { - Vue.nextTick(() => { - expect( - vm.$el.querySelector('.time-tracking-comparison-pane .progress[value="5"]'), - ).not.toBeNull(); - done(); - }); - }); + it('should display the remaining meter with the correct background color when within estimate', () => { + expect(findTimeRemainingProgress().attributes('variant')).toBe('primary'); + }); - it('should display the remaining meter with the correct background color when within estimate', done => { - Vue.nextTick(() => { - expect( - vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="primary"]'), - ).not.toBeNull(); - done(); - }); + it('should display the remaining meter with the correct background color when over estimate', () => { + wrapper = mountComponent({ + props: { + timeEstimate: 10_000, // 2h 46m + timeSpent: 20_000_000, // 231 days + }, }); - it('should display the remaining meter with the correct background color when over estimate', done => { - vm.timeEstimate = 10000; // 2h 46m - vm.timeSpent = 20000000; // 231 days - Vue.nextTick(() => { - expect( - vm.$el.querySelector('.time-tracking-comparison-pane .progress[variant="danger"]'), - ).not.toBeNull(); - done(); - }); - }); + expect(findTimeRemainingProgress().attributes('variant')).toBe('danger'); }); }); + }); - describe('Comparison pane when limitToHours is true', () => { - beforeEach(() => { - initTimeTrackingComponent({ - timeEstimate: 100000, // 1d 3h - timeSpent: 5000, // 1h 23m - timeEstimateHumanReadable: '', - timeSpentHumanReadable: '', + describe('Comparison pane when limitToHours is true', () => { + beforeEach(async () => { + wrapper = mountComponent({ + props: { + timeEstimate: 100_000, // 1d 3h limitToHours: true, - }); + }, }); + }); - it('should show the correct tooltip text', done => { - Vue.nextTick(() => { - expect(vm.showComparisonState).toBe(true); - const $title = vm.$el.querySelector('.time-tracking-content .compare-meter').title; + it('should show the correct tooltip text', async () => { + expect(findByTestId('timeTrackingComparisonPane').exists()).toBe(true); + await wrapper.vm.$nextTick(); - expect($title).toBe('Time remaining: 26h 23m'); - done(); - }); - }); + expect(findComparisonMeter()).toBe('Time remaining: 26h 23m'); }); + }); - describe('Estimate only pane', () => { - beforeEach(() => { - initTimeTrackingComponent({ - timeEstimate: 10000, // 2h 46m + describe('Estimate only pane', () => { + beforeEach(async () => { + wrapper = mountComponent({ + props: { + timeEstimate: 10_000, // 2h 46m timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '', - }); + }, }); + await wrapper.vm.$nextTick(); + }); - it('should display the human readable version of time estimated', done => { - Vue.nextTick(() => { - const estimateText = vm.$el.querySelector('.time-tracking-estimate-only-pane') - .textContent; - const correctText = 'Estimated: 2h 46m'; - - expect(estimateText.trim()).toBe(correctText); - done(); - }); - }); + it('should display the human readable version of time estimated', () => { + const estimateText = findByTestId('estimateOnlyPane').text(); + expect(estimateText.trim()).toBe('Estimated: 2h 46m'); }); + }); - describe('Spent only pane', () => { - beforeEach(() => { - initTimeTrackingComponent({ + describe('Spent only pane', () => { + beforeEach(() => { + wrapper = mountComponent({ + props: { timeEstimate: 0, - timeSpent: 5000, // 1h 23m + timeSpent: 5_000, // 1h 23m timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m', - }); + }, }); + }); - it('should display the human readable version of time spent', done => { - Vue.nextTick(() => { - const spentText = vm.$el.querySelector('.time-tracking-spend-only-pane').textContent; - const correctText = 'Spent: 1h 23m'; - - expect(spentText).toBe(correctText); - done(); - }); - }); + it('should display the human readable version of time spent', () => { + const spentText = findByTestId('spentOnlyPane').text(); + expect(spentText.trim()).toBe('Spent: 1h 23m'); }); + }); - describe('No time tracking pane', () => { - beforeEach(() => { - initTimeTrackingComponent({ + describe('No time tracking pane', () => { + beforeEach(() => { + wrapper = mountComponent({ + props: { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: '', timeSpentHumanReadable: '', - }); + }, }); + }); - it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', done => { - Vue.nextTick(() => { - const $noTrackingPane = vm.$el.querySelector('.time-tracking-no-tracking-pane'); - const noTrackingText = $noTrackingPane.textContent; - const correctText = 'No estimate or time spent'; - - expect(vm.showNoTimeTrackingState).toBe(true); - expect($noTrackingPane).toBeVisible(); - expect(noTrackingText.trim()).toBe(correctText); - done(); - }); - }); + it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', () => { + const pane = findByTestId('noTrackingPane'); + const correctText = 'No estimate or time spent'; + expect(pane.exists()).toBe(true); + expect(pane.text().trim()).toBe(correctText); }); + }); + + describe('Help pane', () => { + const findHelpButton = () => findByTestId('helpButton'); + const findCloseHelpButton = () => findByTestId('closeHelpButton'); - describe('Help pane', () => { - const helpButton = () => vm.$el.querySelector('.help-button'); - const closeHelpButton = () => vm.$el.querySelector('.close-help-button'); - const helpPane = () => vm.$el.querySelector('.time-tracking-help-state'); + beforeEach(async () => { + wrapper = mountComponent({ props: { timeEstimate: 0, timeSpent: 0 } }); + await wrapper.vm.$nextTick(); + }); - beforeEach(() => { - initTimeTrackingComponent({ timeEstimate: 0, timeSpent: 0 }); + it('should not show the "Help" pane by default', () => { + expect(findByTestId('helpPane').exists()).toBe(false); + }); - return vm.$nextTick(); - }); + it('should show the "Help" pane when help button is clicked', async () => { + findHelpButton().trigger('click'); - it('should not show the "Help" pane by default', () => { - expect(vm.showHelpState).toBe(false); - expect(helpPane()).toBeNull(); - }); + await wrapper.vm.$nextTick(); - it('should show the "Help" pane when help button is clicked', () => { - helpButton().click(); + expect(findByTestId('helpPane').exists()).toBe(true); + }); - return vm.$nextTick().then(() => { - expect(vm.showHelpState).toBe(true); + it('should not show the "Help" pane when help button is clicked and then closed', async () => { + findHelpButton().trigger('click'); + await wrapper.vm.$nextTick(); - // let animations run - jest.advanceTimersByTime(500); + expect(findByTestId('helpPane').classes('help-state-toggle-enter')).toBe(true); + expect(findByTestId('helpPane').classes('help-state-toggle-leave')).toBe(false); - expect(helpPane()).toBeVisible(); - }); - }); + findCloseHelpButton().trigger('click'); + await wrapper.vm.$nextTick(); - it('should not show the "Help" pane when help button is clicked and then closed', done => { - helpButton().click(); - - Vue.nextTick() - .then(() => closeHelpButton().click()) - .then(() => Vue.nextTick()) - .then(() => { - expect(vm.showHelpState).toBe(false); - expect(helpPane()).toBeNull(); - }) - .then(done) - .catch(done.fail); - }); + expect(findByTestId('helpPane').classes('help-state-toggle-leave')).toBe(true); + expect(findByTestId('helpPane').classes('help-state-toggle-enter')).toBe(false); }); }); }); diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/issuable_assignees_spec.js index aa930bd4198..076616de040 100644 --- a/spec/frontend/sidebar/issuable_assignees_spec.js +++ b/spec/frontend/sidebar/issuable_assignees_spec.js @@ -13,7 +13,6 @@ describe('IssuableAssignees', () => { propsData: { ...props }, }); }; - const findLabel = () => wrapper.find('[data-testid="assigneeLabel"'); const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList); const findEmptyAssignee = () => wrapper.find('[data-testid="none"]'); @@ -30,10 +29,6 @@ describe('IssuableAssignees', () => { it('renders "None"', () => { expect(findEmptyAssignee().text()).toBe('None'); }); - - it('renders "0 assignees"', () => { - expect(findLabel().text()).toBe('0 Assignees'); - }); }); describe('when assignees are present', () => { @@ -42,18 +37,5 @@ describe('IssuableAssignees', () => { expect(findUncollapsedAssigneeList().exists()).toBe(true); }); - - it.each` - assignees | expected - ${[{ id: 1 }]} | ${'Assignee'} - ${[{ id: 1 }, { id: 2 }]} | ${'2 Assignees'} - `( - 'when assignees have a length of $assignees.length, it renders $expected', - ({ assignees, expected }) => { - createComponent({ users: assignees }); - - expect(findLabel().text()).toBe(expected); - }, - ); }); }); diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index 7a687ffa761..36d1e129b6a 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -1,16 +1,18 @@ import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; import { mockLabels, mockRegularLabel, } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; -import axios from '~/lib/utils/axios_utils'; +import updateIssueLabelsMutation from '~/boards/queries/issue_set_labels.mutation.graphql'; +import { MutationOperationMode } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue'; +import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; +import { toLabelGid } from '~/sidebar/utils'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; describe('sidebar labels', () => { - let axiosMock; let wrapper; const defaultProps = { @@ -23,29 +25,52 @@ describe('sidebar labels', () => { issuableType: 'issue', labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true', labelsManagePath: '/gitlab-org/gitlab-test/-/labels', - labelsUpdatePath: '/gitlab-org/gitlab-test/-/issues/1.json', projectIssuesPath: '/gitlab-org/gitlab-test/-/issues', projectPath: 'gitlab-org/gitlab-test', }; + const $apollo = { + mutate: jest.fn().mockResolvedValue(), + }; + + const userUpdatedLabels = [ + { + ...mockRegularLabel, + set: false, + }, + { + id: 40, + title: 'Security', + color: '#ddd', + text_color: '#fff', + set: true, + }, + { + id: 55, + title: 'Tooling', + color: '#ddd', + text_color: '#fff', + set: false, + }, + ]; + const findLabelsSelect = () => wrapper.find(LabelsSelect); - const mountComponent = () => { + const mountComponent = (props = {}) => { wrapper = shallowMount(SidebarLabels, { provide: { ...defaultProps, + ...props, + }, + mocks: { + $apollo, }, }); }; - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - }); - afterEach(() => { wrapper.destroy(); wrapper = null; - axiosMock.restore(); }); describe('LabelsSelect props', () => { @@ -72,64 +97,94 @@ describe('sidebar labels', () => { }); }); - describe('when labels are updated', () => { + describe('when type is issue', () => { beforeEach(() => { - mountComponent(); + mountComponent({ issuableType: IssuableType.Issue }); }); - it('makes an API call to update labels', async () => { - const labels = [ - { - ...mockRegularLabel, - set: false, - }, - { - id: 40, - title: 'Security', - color: '#ddd', - text_color: '#fff', - set: true, - }, - { - id: 55, - title: 'Tooling', - color: '#ddd', - text_color: '#fff', - set: false, - }, - ]; - - findLabelsSelect().vm.$emit('updateSelectedLabels', labels); - - await axios.waitForAll(); - - const expected = { - [defaultProps.issuableType]: { - label_ids: [27, 28, 29, 40], - }, - }; - - expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected)); + describe('when labels are updated', () => { + it('invokes a mutation', () => { + findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels); + + const expected = { + mutation: updateIssueLabelsMutation, + variables: { + input: { + addLabelIds: [40], + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + removeLabelIds: [26, 55], + }, + }, + }; + + expect($apollo.mutate).toHaveBeenCalledWith(expected); + }); + }); + + describe('when label `x` is clicked', () => { + it('invokes a mutation', () => { + findLabelsSelect().vm.$emit('onLabelRemove', 27); + + const expected = { + mutation: updateIssueLabelsMutation, + variables: { + input: { + iid: defaultProps.iid, + projectPath: defaultProps.projectPath, + removeLabelIds: [27], + }, + }, + }; + + expect($apollo.mutate).toHaveBeenCalledWith(expected); + }); }); }); - describe('when label `x` is clicked', () => { + describe('when type is merge_request', () => { beforeEach(() => { - mountComponent(); + mountComponent({ issuableType: IssuableType.MergeRequest }); }); - it('makes an API call to update labels', async () => { - findLabelsSelect().vm.$emit('onLabelRemove', 27); - - await axios.waitForAll(); - - const expected = { - [defaultProps.issuableType]: { - label_ids: [26, 28, 29], - }, - }; + describe('when labels are updated', () => { + it('invokes a mutation', () => { + findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels); + + const expected = { + mutation: updateMergeRequestLabelsMutation, + variables: { + input: { + iid: defaultProps.iid, + labelIds: [toLabelGid(27), toLabelGid(28), toLabelGid(29), toLabelGid(40)], + operationMode: MutationOperationMode.Replace, + projectPath: defaultProps.projectPath, + }, + }, + }; + + expect($apollo.mutate).toHaveBeenCalledWith(expected); + }); + }); - expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected)); + describe('when label `x` is clicked', () => { + it('invokes a mutation', () => { + findLabelsSelect().vm.$emit('onLabelRemove', 27); + + const expected = { + mutation: updateMergeRequestLabelsMutation, + variables: { + input: { + iid: defaultProps.iid, + labelIds: [toLabelGid(27)], + operationMode: MutationOperationMode.Remove, + projectPath: defaultProps.projectPath, + }, + }, + }; + + expect($apollo.mutate).toHaveBeenCalledWith(expected); + }); }); }); }); diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js index dddb9c2bba9..428441656b3 100644 --- a/spec/frontend/sidebar/subscriptions_spec.js +++ b/spec/frontend/sidebar/subscriptions_spec.js @@ -94,7 +94,7 @@ describe('Subscriptions', () => { it('sets the correct display text', () => { expect(wrapper.find('.issuable-header-text').text()).toContain(subscribeDisabledDescription); - expect(wrapper.find({ ref: 'tooltip' }).attributes('data-original-title')).toBe( + expect(wrapper.find({ ref: 'tooltip' }).attributes('title')).toBe( subscribeDisabledDescription, ); }); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 93684ed48ee..cef5f8cc528 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -52,9 +52,13 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = <div class="div-dropzone-hover" > - <i - class="fa fa-paperclip div-dropzone-icon" - /> + <svg + class="div-dropzone-icon s24" + > + <use + xlink:href="undefined#paperclip" + /> + </svg> </div> </div> diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index c1fad8cebe6..3521733ee5e 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -1,7 +1,9 @@ -import { ApolloMutation } from 'vue-apollo'; +import VueApollo, { ApolloMutation } from 'vue-apollo'; import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql'; import { deprecatedCreateFlash as Flash } from '~/flash'; import * as urlUtils from '~/lib/utils/url_utility'; import SnippetEditApp from '~/snippets/components/edit.vue'; @@ -10,7 +12,11 @@ import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; import TitleField from '~/vue_shared/components/form/title.vue'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; -import { SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants'; +import { + SNIPPET_VISIBILITY_PRIVATE, + SNIPPET_VISIBILITY_INTERNAL, + SNIPPET_VISIBILITY_PUBLIC, +} from '~/snippets/constants'; import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql'; import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql'; import { testEntries } from '../test_utils'; @@ -47,8 +53,12 @@ const createTestSnippet = () => ({ describe('Snippet Edit app', () => { let wrapper; + let fakeApollo; const relativeUrlRoot = '/foo/'; const originalRelativeUrlRoot = gon.relative_url_root; + const GetSnippetQuerySpy = jest.fn().mockResolvedValue({ + data: { snippets: { nodes: [createTestSnippet()] } }, + }); const mutationTypes = { RESOLVE: jest.fn().mockResolvedValue({ @@ -78,12 +88,10 @@ describe('Snippet Edit app', () => { props = {}, loading = false, mutationRes = mutationTypes.RESOLVE, + selectedLevel = SNIPPET_VISIBILITY_PRIVATE, + withApollo = false, } = {}) { - if (wrapper) { - throw new Error('wrapper already exists'); - } - - wrapper = shallowMount(SnippetEditApp, { + let componentData = { mocks: { $apollo: { queries: { @@ -92,23 +100,35 @@ describe('Snippet Edit app', () => { mutate: mutationRes, }, }, + }; + + if (withApollo) { + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const requestHandlers = [[GetSnippetQuery, GetSnippetQuerySpy]]; + fakeApollo = createMockApollo(requestHandlers); + componentData = { + localVue, + apolloProvider: fakeApollo, + }; + } + + wrapper = shallowMount(SnippetEditApp, { + ...componentData, stubs: { ApolloMutation, FormFooterActions, }, + provide: { + selectedLevel, + }, propsData: { snippetGid: 'gid://gitlab/PersonalSnippet/42', markdownPreviewPath: 'http://preview.foo.bar', markdownDocsPath: 'http://docs.foo.bar', ...props, }, - data() { - return { - snippet: { - visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, - }, - }; - }, }); } @@ -152,16 +172,13 @@ describe('Snippet Edit app', () => { if (nodes.length) { wrapper.setData({ snippet: nodes[0], + newSnippet: false, + }); + } else { + wrapper.setData({ + newSnippet: true, }); } - - wrapper.vm.onSnippetFetch({ - data: { - snippets: { - nodes, - }, - }, - }); }; describe('rendering', () => { @@ -228,6 +245,28 @@ describe('Snippet Edit app', () => { }); describe('functionality', () => { + it('does not fetch snippet when create a new snippet', async () => { + createComponent({ props: { snippetGid: '' }, withApollo: true }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + expect(GetSnippetQuerySpy).not.toHaveBeenCalled(); + }); + + describe('default visibility', () => { + it.each([SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC])( + 'marks %s visibility by default', + async visibility => { + createComponent({ + props: { snippetGid: '' }, + selectedLevel: visibility, + }); + expect(wrapper.vm.snippet.visibilityLevel).toEqual(visibility); + }, + ); + }); + describe('form submission handling', () => { it.each` snippetArg | projectPath | uploadedFiles | input | mutation diff --git a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js index 3919e4d7993..3151090f388 100644 --- a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js @@ -1,7 +1,6 @@ import { GlFormRadio, GlIcon, GlFormRadioGroup, GlLink } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue'; -import { defaultSnippetVisibilityLevels } from '~/snippets/utils/blob'; import { SNIPPET_VISIBILITY, SNIPPET_VISIBILITY_PRIVATE, @@ -15,36 +14,25 @@ describe('Snippet Visibility Edit component', () => { let wrapper; const defaultHelpLink = '/foo/bar'; const defaultVisibilityLevel = 'private'; - const defaultVisibility = defaultSnippetVisibilityLevels([0, 10, 20]); function createComponent({ propsData = {}, - visibilityLevels = defaultVisibility, + visibilityLevels = [0, 10, 20], multipleLevelsRestricted = false, deep = false, } = {}) { const method = deep ? mount : shallowMount; - const $apollo = { - queries: { - defaultVisibility: { - loading: false, - }, - }, - }; wrapper = method.call(this, SnippetVisibilityEdit, { - mock: { $apollo }, propsData: { helpLink: defaultHelpLink, isProjectSnippet: false, value: defaultVisibilityLevel, ...propsData, }, - data() { - return { - visibilityLevels, - multipleLevelsRestricted, - }; + provide: { + visibilityLevels, + multipleLevelsRestricted, }, }); } @@ -108,7 +96,6 @@ describe('Snippet Visibility Edit component', () => { it.each` levels | resultOptions - ${undefined} | ${[]} ${''} | ${[]} ${[]} | ${[]} ${[0]} | ${[RESULTING_OPTIONS[0]]} @@ -117,7 +104,7 @@ describe('Snippet Visibility Edit component', () => { ${[0, 20]} | ${[RESULTING_OPTIONS[0], RESULTING_OPTIONS[20]]} ${[10, 20]} | ${[RESULTING_OPTIONS[10], RESULTING_OPTIONS[20]]} `('renders correct visibility options for $levels', ({ levels, resultOptions }) => { - createComponent({ visibilityLevels: defaultSnippetVisibilityLevels(levels), deep: true }); + createComponent({ visibilityLevels: levels, deep: true }); expect(findRadiosData()).toEqual(resultOptions); }); @@ -132,7 +119,7 @@ describe('Snippet Visibility Edit component', () => { 'renders correct information about restricted visibility levels for $levels', ({ levels, levelsRestricted, resultText }) => { createComponent({ - visibilityLevels: defaultSnippetVisibilityLevels(levels), + visibilityLevels: levels, multipleLevelsRestricted: levelsRestricted, }); expect(findRestrictedInfo().text()).toBe(resultText); diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js index 7e90b53dd07..247aff57c1a 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -15,6 +15,11 @@ import { sourceContentHeaderObjYAML as headerSettings, sourceContentBody as body, returnUrl, + mounts, + project, + branch, + baseUrl, + imageRoot, } from '../mock_data'; jest.mock('~/static_site_editor/services/formatter', () => jest.fn(str => `${str} format-pass`)); @@ -31,6 +36,11 @@ describe('~/static_site_editor/components/edit_area.vue', () => { title, content, returnUrl, + mounts, + project, + branch, + baseUrl, + imageRoot, savingChanges, ...propsData, }, diff --git a/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js b/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js index 191f91be076..b887570e947 100644 --- a/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js +++ b/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js @@ -1,15 +1,12 @@ import { shallowMount } from '@vue/test-utils'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { GlFormInput, GlFormTextarea } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlFormInput, GlFormTextarea } from '@gitlab/ui'; import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue'; -import { mergeRequestMeta } from '../mock_data'; +import { mergeRequestMeta, mergeRequestTemplates } from '../mock_data'; describe('~/static_site_editor/components/edit_meta_controls.vue', () => { - useLocalStorageSpy(); - let wrapper; let mockSelect; let mockGlFormInputTitleInstance; @@ -22,6 +19,8 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => { propsData: { title, description, + templates: mergeRequestTemplates, + currentTemplate: null, ...propsData, }, }); @@ -34,6 +33,10 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => { }; const findGlFormInputTitle = () => wrapper.find(GlFormInput); + const findGlDropdownDescriptionTemplate = () => wrapper.find(GlDropdown); + const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findDropdownItemByIndex = index => findAllDropdownItems().at(index); + const findGlFormTextAreaDescription = () => wrapper.find(GlFormTextarea); beforeEach(() => { @@ -52,6 +55,10 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => { expect(findGlFormInputTitle().exists()).toBe(true); }); + it('renders the description template dropdown', () => { + expect(findGlDropdownDescriptionTemplate().exists()).toBe(true); + }); + it('renders the description input', () => { expect(findGlFormTextAreaDescription().exists()).toBe(true); }); @@ -68,6 +75,11 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => { expect(mockGlFormInputTitleInstance.$el.select).toHaveBeenCalled(); }); + it('renders a GlDropdownItem per template plus one (for the starting none option)', () => { + expect(findDropdownItemByIndex(0).text()).toBe('None'); + expect(findAllDropdownItems().length).toBe(mergeRequestTemplates.length + 1); + }); + describe('when inputs change', () => { const storageKey = 'sse-merge-request-meta-local-storage-editable'; @@ -86,14 +98,18 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => { expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings); }); + }); - it('should remember the input changes', () => { - findGlFormInputTitle().vm.$emit('input', newTitle); - findGlFormTextAreaDescription().vm.$emit('input', newDescription); - - const newSettings = { title: newTitle, description: newDescription }; - - expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, JSON.stringify(newSettings)); + describe('when templates change', () => { + it.each` + index | value + ${0} | ${null} + ${1} | ${mergeRequestTemplates[0]} + ${2} | ${mergeRequestTemplates[1]} + `('emits a change template event when $index is clicked', ({ index, value }) => { + findDropdownItemByIndex(index).vm.$emit('click'); + + expect(wrapper.emitted('changeTemplate')[0][0]).toBe(value); }); }); }); diff --git a/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js b/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js index 7a5685033f3..c7d0abee05c 100644 --- a/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js +++ b/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js @@ -1,48 +1,87 @@ import { shallowMount } from '@vue/test-utils'; - import { GlModal } from '@gitlab/ui'; - +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import EditMetaModal from '~/static_site_editor/components/edit_meta_modal.vue'; import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue'; - -import { sourcePath, mergeRequestMeta } from '../mock_data'; +import { MR_META_LOCAL_STORAGE_KEY } from '~/static_site_editor/constants'; +import { + sourcePath, + mergeRequestMeta, + mergeRequestTemplates, + project as namespaceProject, +} from '../mock_data'; describe('~/static_site_editor/components/edit_meta_modal.vue', () => { + useLocalStorageSpy(); + let wrapper; - let resetCachedEditable; - let mockEditMetaControlsInstance; + let mockAxios; const { title, description } = mergeRequestMeta; + const [namespace, project] = namespaceProject.split('/'); - const buildWrapper = (propsData = {}) => { + const buildWrapper = (propsData = {}, data = {}) => { wrapper = shallowMount(EditMetaModal, { propsData: { sourcePath, + namespace, + project, ...propsData, }, + data: () => data, }); }; - const buildMocks = () => { - resetCachedEditable = jest.fn(); - mockEditMetaControlsInstance = { resetCachedEditable }; - wrapper.vm.$refs.editMetaControls = mockEditMetaControlsInstance; + const buildMockAxios = () => { + mockAxios = new MockAdapter(axios); + const templatesMergeRequestsPath = `templates/merge_request`; + mockAxios + .onGet(`${namespace}/${project}/${templatesMergeRequestsPath}`) + .reply(200, mergeRequestTemplates); + }; + + const buildMockRefs = () => { + wrapper.vm.$refs.editMetaControls = { resetCachedEditable: jest.fn() }; }; const findGlModal = () => wrapper.find(GlModal); const findEditMetaControls = () => wrapper.find(EditMetaControls); + const findLocalStorageSync = () => wrapper.find(LocalStorageSync); beforeEach(() => { + localStorage.setItem(MR_META_LOCAL_STORAGE_KEY); + + buildMockAxios(); buildWrapper(); - buildMocks(); + buildMockRefs(); return wrapper.vm.$nextTick(); }); afterEach(() => { + mockAxios.restore(); + wrapper.destroy(); wrapper = null; }); + it('initializes initial merge request meta with local storage data', async () => { + const localStorageMeta = { + title: 'stored title', + description: 'stored description', + templates: null, + currentTemplate: null, + }; + + findLocalStorageSync().vm.$emit('input', localStorageMeta); + + await wrapper.vm.$nextTick(); + + expect(findEditMetaControls().props()).toEqual(localStorageMeta); + }); + it('renders the modal', () => { expect(findGlModal().exists()).toBe(true); }); @@ -63,18 +102,70 @@ describe('~/static_site_editor/components/edit_meta_modal.vue', () => { expect(findEditMetaControls().props('description')).toBe(description); }); - it('emits the primary event with mergeRequestMeta', () => { - findGlModal().vm.$emit('primary', mergeRequestMeta); - expect(wrapper.emitted('primary')).toEqual([[mergeRequestMeta]]); + it('forwards the templates prop', () => { + expect(findEditMetaControls().props('templates')).toBe(null); + }); + + it('forwards the currentTemplate prop', () => { + expect(findEditMetaControls().props('currentTemplate')).toBe(null); + }); + + describe('when save button is clicked', () => { + beforeEach(() => { + findGlModal().vm.$emit('primary', mergeRequestMeta); + }); + + it('removes merge request meta from local storage', () => { + expect(findLocalStorageSync().props().clear).toBe(true); + }); + + it('emits the primary event with mergeRequestMeta', () => { + expect(wrapper.emitted('primary')).toEqual([[mergeRequestMeta]]); + }); }); - it('calls resetCachedEditable on EditMetaControls when primary emits', () => { - findGlModal().vm.$emit('primary', mergeRequestMeta); - expect(mockEditMetaControlsInstance.resetCachedEditable).toHaveBeenCalled(); + describe('when templates exist', () => { + const template1 = mergeRequestTemplates[0]; + + beforeEach(() => { + buildWrapper({}, { templates: mergeRequestTemplates, currentTemplate: null }); + }); + + it('sets the currentTemplate on the changeTemplate event', async () => { + findEditMetaControls().vm.$emit('changeTemplate', template1); + + await wrapper.vm.$nextTick(); + + expect(findEditMetaControls().props().currentTemplate).toBe(template1); + + findEditMetaControls().vm.$emit('changeTemplate', null); + + await wrapper.vm.$nextTick(); + + expect(findEditMetaControls().props().currentTemplate).toBe(null); + }); + + it('updates the description on the changeTemplate event', async () => { + findEditMetaControls().vm.$emit('changeTemplate', template1); + + await wrapper.vm.$nextTick(); + + expect(findEditMetaControls().props().description).toEqual(template1.content); + }); }); it('emits the hide event', () => { findGlModal().vm.$emit('hide'); expect(wrapper.emitted('hide')).toEqual([[]]); }); + + it('stores merge request meta changes in local storage when changes happen', async () => { + const newMeta = { title: 'new title', description: 'new description' }; + + findEditMetaControls().vm.$emit('updateSettings', newMeta); + + await wrapper.vm.$nextTick(); + + expect(findLocalStorageSync().props('value')).toEqual(newMeta); + }); }); diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js index 0b08e290227..8bc65c6ce31 100644 --- a/spec/frontend/static_site_editor/mock_data.js +++ b/spec/frontend/static_site_editor/mock_data.js @@ -27,6 +27,7 @@ export const sourceContentTitle = 'Handbook'; export const username = 'gitlabuser'; export const projectId = '123456'; +export const project = 'user1/project1'; export const returnUrl = 'https://www.gitlab.com'; export const sourcePath = 'foobar.md.html'; export const mergeRequestMeta = { @@ -47,6 +48,10 @@ export const savedContentMeta = { url: 'foobar/-/merge_requests/123', }, }; +export const mergeRequestTemplates = [ + { key: 'Template1', name: 'Template 1', content: 'This is template 1!' }, + { key: 'Template2', name: 'Template 2', content: 'This is template 2!' }, +]; export const submitChangesError = 'Could not save changes'; export const commitBranchResponse = { @@ -67,3 +72,20 @@ export const images = new Map([ ['path/to/image1.png', 'image1-content'], ['path/to/image2.png', 'image2-content'], ]); + +export const mounts = [ + { + source: 'default/source/', + target: '', + }, + { + source: 'source/with/target', + target: 'target', + }, +]; + +export const branch = 'master'; + +export const baseUrl = '/user1/project1/-/sse/master%2Ftest.md'; + +export const imageRoot = 'source/images/'; diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js index 2c69e884005..d0b72ad0cf0 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -12,7 +12,7 @@ import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants'; import { TRACKING_ACTION_INITIALIZE_EDITOR } from '~/static_site_editor/constants'; import { - projectId as project, + project, returnUrl, sourceContentYAML as content, sourceContentTitle as title, @@ -23,6 +23,10 @@ import { submitChangesError, trackingCategory, images, + mounts, + branch, + baseUrl, + imageRoot, } from '../mock_data'; const localVue = createLocalVue(); @@ -41,6 +45,10 @@ describe('static_site_editor/pages/home', () => { project, username, sourcePath, + mounts, + branch, + baseUrl, + imageUploadPath: imageRoot, }; const hasSubmittedChangesMutationPayload = { data: { @@ -119,6 +127,7 @@ describe('static_site_editor/pages/home', () => { it('provides source content, returnUrl, and isSavingChanges to the edit area', () => { expect(findEditArea().props()).toMatchObject({ title, + mounts, content, returnUrl, savingChanges: false, diff --git a/spec/frontend/static_site_editor/services/front_matterify_spec.js b/spec/frontend/static_site_editor/services/front_matterify_spec.js index dbaedc30849..866897f21ef 100644 --- a/spec/frontend/static_site_editor/services/front_matterify_spec.js +++ b/spec/frontend/static_site_editor/services/front_matterify_spec.js @@ -11,6 +11,7 @@ describe('static_site_editor/services/front_matterify', () => { const frontMatterifiedContent = { source: content, matter: yamlFrontMatterObj, + hasMatter: true, spacing, content: body, delimiter: '---', @@ -19,6 +20,7 @@ describe('static_site_editor/services/front_matterify', () => { const frontMatterifiedBody = { source: body, matter: null, + hasMatter: false, spacing: null, content: body, delimiter: null, @@ -33,6 +35,12 @@ describe('static_site_editor/services/front_matterify', () => { `('returns $target from $frontMatterified', ({ frontMatterified, target }) => { expect(frontMatterified).toEqual(target); }); + + it('should throw when matter is invalid', () => { + const invalidContent = `---\nkey: val\nkeyNoVal\n---\n${body}`; + + expect(() => frontMatterify(invalidContent)).toThrow(); + }); }); describe('stringify', () => { diff --git a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js new file mode 100644 index 00000000000..e9e40835982 --- /dev/null +++ b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js @@ -0,0 +1,96 @@ +import imageRenderer from '~/static_site_editor/services/renderers/render_image'; +import { mounts, project, branch, baseUrl } from '../../mock_data'; + +describe('rich_content_editor/renderers/render_image', () => { + let renderer; + let imageRepository; + + beforeEach(() => { + renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository); + imageRepository = { get: () => null }; + }); + + describe('build', () => { + it('builds a renderer object containing `canRender` and `render` functions', () => { + expect(renderer).toHaveProperty('canRender', expect.any(Function)); + expect(renderer).toHaveProperty('render', expect.any(Function)); + }); + }); + + describe('canRender', () => { + it.each` + input | result + ${{ type: 'image' }} | ${true} + ${{ type: 'text' }} | ${false} + ${{ type: 'htmlBlock' }} | ${false} + `('returns $result when input is $input', ({ input, result }) => { + expect(renderer.canRender(input)).toBe(result); + }); + }); + + describe('render', () => { + let skipChildren; + let context; + let node; + + beforeEach(() => { + skipChildren = jest.fn(); + context = { skipChildren }; + node = { + firstChild: { + type: 'img', + literal: 'Some Image', + }, + }; + }); + + it.each` + destination | isAbsolute | src + ${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'} + ${'/relative/path/to/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/default/source/relative/path/to/image.png'} + ${'/target/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/source/with/target/image.png'} + ${'relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/relative/to/current/image.png'} + ${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/./relative/to/current/image.png'} + ${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/../relative/to/current/image.png'} + `('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => { + node.destination = destination; + + const result = renderer.render(node, context); + + expect(result).toEqual({ + type: 'openTag', + tagName: 'img', + selfClose: true, + attributes: { + 'data-original-src': !isAbsolute ? destination : '', + src, + alt: 'Some Image', + }, + }); + + expect(skipChildren).toHaveBeenCalled(); + }); + + it('renders an image if a cached image is found in the repository, use the base64 content as the source', () => { + const imageContent = 'some-content'; + const originalSrc = 'path/to/image.png'; + + imageRepository.get = () => imageContent; + renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository); + node.destination = originalSrc; + + const result = renderer.render(node, context); + + expect(result).toEqual({ + type: 'openTag', + tagName: 'img', + selfClose: true, + attributes: { + 'data-original-src': originalSrc, + src: `data:image;base64,${imageContent}`, + alt: 'Some Image', + }, + }); + }); + }); +}); diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js new file mode 100644 index 00000000000..c86160e18f3 --- /dev/null +++ b/spec/frontend/terraform/components/empty_state_spec.js @@ -0,0 +1,26 @@ +import { GlEmptyState, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import EmptyState from '~/terraform/components/empty_state.vue'; + +describe('EmptyStateComponent', () => { + let wrapper; + + const propsData = { + image: '/image/path', + }; + + beforeEach(() => { + wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlSprintf } }); + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should render content', () => { + expect(wrapper.find(GlEmptyState).exists()).toBe(true); + expect(wrapper.text()).toContain('Get started with Terraform'); + }); +}); diff --git a/spec/frontend/terraform/components/states_table_spec.js b/spec/frontend/terraform/components/states_table_spec.js new file mode 100644 index 00000000000..7a8cb19971e --- /dev/null +++ b/spec/frontend/terraform/components/states_table_spec.js @@ -0,0 +1,102 @@ +import { GlIcon, GlTooltip } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { useFakeDate } from 'helpers/fake_date'; +import StatesTable from '~/terraform/components/states_table.vue'; + +describe('StatesTable', () => { + let wrapper; + useFakeDate([2020, 10, 15]); + + const propsData = { + states: [ + { + name: 'state-1', + lockedAt: '2020-10-13T00:00:00Z', + lockedByUser: { + name: 'user-1', + }, + updatedAt: '2020-10-13T00:00:00Z', + latestVersion: null, + }, + { + name: 'state-2', + lockedAt: null, + lockedByUser: null, + updatedAt: '2020-10-10T00:00:00Z', + latestVersion: null, + }, + { + name: 'state-3', + lockedAt: '2020-10-10T00:00:00Z', + lockedByUser: { + name: 'user-2', + }, + updatedAt: '2020-10-10T00:00:00Z', + latestVersion: { + updatedAt: '2020-10-11T00:00:00Z', + createdByUser: { + name: 'user-3', + }, + }, + }, + { + name: 'state-4', + lockedAt: '2020-10-10T00:00:00Z', + lockedByUser: null, + updatedAt: '2020-10-10T00:00:00Z', + latestVersion: { + updatedAt: '2020-10-09T00:00:00Z', + createdByUser: null, + }, + }, + ], + }; + + beforeEach(() => { + wrapper = mount(StatesTable, { propsData }); + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each` + name | toolTipText | locked | lineNumber + ${'state-1'} | ${'Locked by user-1 2 days ago'} | ${true} | ${0} + ${'state-2'} | ${null} | ${false} | ${1} + ${'state-3'} | ${'Locked by user-2 5 days ago'} | ${true} | ${2} + ${'state-4'} | ${'Locked by Unknown User 5 days ago'} | ${true} | ${3} + `( + 'displays the name and locked information "$name" for line "$lineNumber"', + ({ name, toolTipText, locked, lineNumber }) => { + const states = wrapper.findAll('[data-testid="terraform-states-table-name"]'); + + const state = states.at(lineNumber); + const toolTip = state.find(GlTooltip); + + expect(state.text()).toContain(name); + expect(state.find(GlIcon).exists()).toBe(locked); + expect(toolTip.exists()).toBe(locked); + + if (locked) { + expect(toolTip.text()).toMatchInterpolatedText(toolTipText); + } + }, + ); + + it.each` + updateTime | lineNumber + ${'updated 2 days ago'} | ${0} + ${'updated 5 days ago'} | ${1} + ${'user-3 updated 4 days ago'} | ${2} + ${'updated 6 days ago'} | ${3} + `('displays the time "$updateTime" for line "$lineNumber"', ({ updateTime, lineNumber }) => { + const states = wrapper.findAll('[data-testid="terraform-states-table-updated"]'); + + const state = states.at(lineNumber); + + expect(state.text()).toMatchInterpolatedText(updateTime); + }); +}); diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js new file mode 100644 index 00000000000..b31afecc816 --- /dev/null +++ b/spec/frontend/terraform/components/terraform_list_spec.js @@ -0,0 +1,172 @@ +import { GlAlert, GlBadge, GlKeysetPagination, GlLoadingIcon, GlTab } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import VueApollo from 'vue-apollo'; +import EmptyState from '~/terraform/components/empty_state.vue'; +import StatesTable from '~/terraform/components/states_table.vue'; +import TerraformList from '~/terraform/components/terraform_list.vue'; +import getStatesQuery from '~/terraform/graphql/queries/get_states.query.graphql'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('TerraformList', () => { + let wrapper; + + const propsData = { + emptyStateImage: '/path/to/image', + projectPath: 'path/to/project', + }; + + const createWrapper = ({ terraformStates, queryResponse = null }) => { + const apolloQueryResponse = { + data: { + project: { + terraformStates, + }, + }, + }; + + const statsQueryResponse = queryResponse || jest.fn().mockResolvedValue(apolloQueryResponse); + const apolloProvider = createMockApollo([[getStatesQuery, statsQueryResponse]]); + + wrapper = shallowMount(TerraformList, { + localVue, + apolloProvider, + propsData, + }); + }; + + const findBadge = () => wrapper.find(GlBadge); + const findEmptyState = () => wrapper.find(EmptyState); + const findPaginationButtons = () => wrapper.find(GlKeysetPagination); + const findStatesTable = () => wrapper.find(StatesTable); + const findTab = () => wrapper.find(GlTab); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when the terraform query has succeeded', () => { + describe('when there is a list of terraform states', () => { + const states = [ + { + id: 'gid://gitlab/Terraform::State/1', + name: 'state-1', + lockedAt: null, + updatedAt: null, + lockedByUser: null, + latestVersion: null, + }, + { + id: 'gid://gitlab/Terraform::State/2', + name: 'state-2', + lockedAt: null, + updatedAt: null, + lockedByUser: null, + latestVersion: null, + }, + ]; + + beforeEach(() => { + createWrapper({ + terraformStates: { + nodes: states, + count: states.length, + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'prev', + endCursor: 'next', + }, + }, + }); + + return wrapper.vm.$nextTick(); + }); + + it('displays a states tab and count', () => { + expect(findTab().text()).toContain('States'); + expect(findBadge().text()).toBe('2'); + }); + + it('renders the states table and pagination buttons', () => { + expect(findStatesTable().exists()).toBe(true); + expect(findPaginationButtons().exists()).toBe(true); + }); + + describe('when list has no additional pages', () => { + beforeEach(() => { + createWrapper({ + terraformStates: { + nodes: states, + count: states.length, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + }, + }); + + return wrapper.vm.$nextTick(); + }); + + it('renders the states table without pagination buttons', () => { + expect(findStatesTable().exists()).toBe(true); + expect(findPaginationButtons().exists()).toBe(false); + }); + }); + }); + + describe('when the list of terraform states is empty', () => { + beforeEach(() => { + createWrapper({ + terraformStates: { + nodes: [], + count: 0, + pageInfo: null, + }, + }); + + return wrapper.vm.$nextTick(); + }); + + it('displays a states tab with no count', () => { + expect(findTab().text()).toContain('States'); + expect(findBadge().exists()).toBe(false); + }); + + it('renders the empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + }); + }); + + describe('when the terraform query has errored', () => { + beforeEach(() => { + createWrapper({ terraformStates: null, queryResponse: jest.fn().mockRejectedValue() }); + + return wrapper.vm.$nextTick(); + }); + + it('displays an alert message', () => { + expect(wrapper.find(GlAlert).exists()).toBe(true); + }); + }); + + describe('when the terraform query is loading', () => { + beforeEach(() => { + createWrapper({ + terraformStates: null, + queryResponse: jest.fn().mockReturnValue(new Promise(() => {})), + }); + }); + + it('displays a loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js index 0edc5248629..50848ca2978 100644 --- a/spec/frontend/tooltips/components/tooltips_spec.js +++ b/spec/frontend/tooltips/components/tooltips_spec.js @@ -80,6 +80,14 @@ describe('tooltips/components/tooltips.vue', () => { expect(wrapper.find(GlTooltip).html()).toContain(target.getAttribute('title')); }); + it('sets the configuration values passed in the config object', async () => { + const config = { show: true }; + target = createTooltipTarget(); + wrapper.vm.addTooltips([target], config); + await wrapper.vm.$nextTick(); + expect(wrapper.find(GlTooltip).props()).toMatchObject(config); + }); + it.each` attribute | value | prop ${'data-placement'} | ${'bottom'} | ${'placement'} diff --git a/spec/frontend/tooltips/index_spec.js b/spec/frontend/tooltips/index_spec.js index cc72adee57d..511003fdb8f 100644 --- a/spec/frontend/tooltips/index_spec.js +++ b/spec/frontend/tooltips/index_spec.js @@ -1,5 +1,15 @@ import jQuery from 'jquery'; -import { initTooltips, dispose, destroy, hide, show, enable, disable, fixTitle } from '~/tooltips'; +import { + add, + initTooltips, + dispose, + destroy, + hide, + show, + enable, + disable, + fixTitle, +} from '~/tooltips'; describe('tooltips/index.js', () => { let tooltipsApp; @@ -67,6 +77,20 @@ describe('tooltips/index.js', () => { }); }); + describe('add', () => { + it('adds a GlTooltip for the specified elements', async () => { + const target = createTooltipTarget(); + + buildTooltipsApp(); + add([target], { title: 'custom title' }); + + await tooltipsApp.$nextTick(); + + expect(document.querySelector('.gl-tooltip')).not.toBe(null); + expect(document.querySelector('.gl-tooltip').innerHTML).toContain('custom title'); + }); + }); + describe('dispose', () => { it('removes tooltips that target the elements specified', async () => { const target = createTooltipTarget(); @@ -136,12 +160,13 @@ describe('tooltips/index.js', () => { ${disable} | ${'disable'} | ${'disable'} ${hide} | ${'hide'} | ${'hide'} ${show} | ${'show'} | ${'show'} + ${add} | ${'init'} | ${{ title: 'the title' }} `('delegates $methodName to bootstrap tooltip API', ({ method, bootstrapParams }) => { const elements = jQuery(createTooltipTarget()); jest.spyOn(jQuery.fn, 'tooltip'); - method(elements); + method(elements, bootstrapParams); expect(elements.tooltip).toHaveBeenCalledWith(bootstrapParams); }); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index 8c2bef60e74..d4b97532cdd 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -31,6 +31,7 @@ describe('Tracking', () => { contexts: { webPage: true, performanceTiming: true }, formTracking: false, linkClickTracking: false, + pageUnloadTimer: 10, }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js new file mode 100644 index 00000000000..8f6fe3cd37a --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js @@ -0,0 +1,31 @@ +import { registerExtension, extensions } from '~/vue_merge_request_widget/components/extensions'; +import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue'; + +describe('MR widget extension registering', () => { + it('registers a extension', () => { + registerExtension({ + name: 'Test', + props: ['helloWorld'], + computed: { + test() {}, + }, + methods: { + test() {}, + }, + }); + + expect(extensions[0]).toEqual( + expect.objectContaining({ + extends: ExtensionBase, + name: 'Test', + props: ['helloWorld'], + computed: { + test: expect.any(Function), + }, + methods: { + test: expect.any(Function), + }, + }), + ); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index 015f8bbac51..266c906ba60 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -1,13 +1,7 @@ import Vue from 'vue'; -import Mousetrap from 'mousetrap'; import mountComponent from 'helpers/vue_mount_component_helper'; import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue'; -jest.mock('mousetrap', () => ({ - bind: jest.fn(), - unbind: jest.fn(), -})); - describe('MRWidgetHeader', () => { let vm; let Component; @@ -136,35 +130,6 @@ describe('MRWidgetHeader', () => { it('renders target branch', () => { expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master'); }); - - describe('keyboard shortcuts', () => { - it('binds a keyboard shortcut handler to the "b" key', () => { - expect(Mousetrap.bind).toHaveBeenCalledWith('b', expect.any(Function)); - }); - - it('triggers a click on the "copy to clipboard" button when the handler is executed', () => { - const testClickHandler = jest.fn(); - vm.$refs.copyBranchNameButton.$el.addEventListener('click', testClickHandler); - - // Get a reference to the function that was assigned to the "b" shortcut key. - const shortcutHandler = Mousetrap.bind.mock.calls[0][1]; - - expect(testClickHandler).not.toHaveBeenCalled(); - - // Simulate Mousetrap calling the function. - shortcutHandler(); - - expect(testClickHandler).toHaveBeenCalledTimes(1); - }); - - it('unbinds the keyboard shortcut when the component is destroyed', () => { - expect(Mousetrap.unbind).not.toHaveBeenCalled(); - - vm.$destroy(); - - expect(Mousetrap.unbind).toHaveBeenCalledWith('b'); - }); - }); }); describe('with an open merge request', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js index 5c7e6a87c16..56832f82b05 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; const commits = [ @@ -39,7 +39,7 @@ describe('Commits message dropdown component', () => { wrapper.destroy(); }); - const findDropdownElements = () => wrapper.findAll(GlDeprecatedDropdownItem); + const findDropdownElements = () => wrapper.findAll(GlDropdownItem); const findFirstDropdownElement = () => findDropdownElements().at(0); it('should have 3 elements in dropdown list', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js index 6ccf1e1f56b..907906ebe98 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -84,7 +84,7 @@ describe('Wip', () => { it('should have correct elements', () => { expect(el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(el.innerText).toContain('This merge request is still a work in progress.'); + expect(el.innerText).toContain('This merge request is still a draft.'); expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); expect(el.querySelector('button').innerText).toContain('Merge'); expect(el.querySelector('.js-remove-wip').innerText.replace(/\s\s+/g, ' ')).toContain( diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index 25c967996e3..d6f85dcfcc7 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -1,7 +1,6 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import mountComponent from 'helpers/vue_mount_component_helper'; -import { withGonExperiment } from 'helpers/experimentation_helper'; import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; @@ -850,7 +849,7 @@ describe('mrWidgetOptions', () => { }); }); - describe('suggestPipeline Experiment', () => { + describe('suggestPipeline feature flag', () => { beforeEach(() => { mock.onAny().reply(200); @@ -859,10 +858,10 @@ describe('mrWidgetOptions', () => { jest.spyOn(console, 'warn').mockImplementation(); }); - describe('given experiment is enabled', () => { - withGonExperiment('suggestPipeline'); - + describe('given feature flag is enabled', () => { beforeEach(() => { + gon.features = { suggestPipeline: true }; + createComponent(); vm.mr.hasCI = false; @@ -893,10 +892,10 @@ describe('mrWidgetOptions', () => { }); }); - describe('given suggestPipeline experiment is not enabled', () => { - withGonExperiment('suggestPipeline', false); - + describe('given feature flag is not enabled', () => { beforeEach(() => { + gon.features = { suggestPipeline: false }; + createComponent(); vm.mr.hasCI = false; diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index 82503e5a025..04ae2a0f34d 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -6,10 +6,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` > <button class="btn award-control" - data-boundary="viewport" - data-original-title="Ada, Leonardo, and Marie" data-testid="award-button" - title="" + title="Ada, Leonardo, and Marie" type="button" > <span @@ -32,10 +30,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control active" - data-boundary="viewport" - data-original-title="You, Ada, and Marie" data-testid="award-button" - title="" + title="You, Ada, and Marie" type="button" > <span @@ -58,10 +54,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control" - data-boundary="viewport" - data-original-title="Ada and Jane" data-testid="award-button" - title="" + title="Ada and Jane" type="button" > <span @@ -84,10 +78,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control active" - data-boundary="viewport" - data-original-title="You, Ada, Jane, and Leonardo" data-testid="award-button" - title="" + title="You, Ada, Jane, and Leonardo" type="button" > <span @@ -110,10 +102,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control active" - data-boundary="viewport" - data-original-title="You" data-testid="award-button" - title="" + title="You" type="button" > <span @@ -136,10 +126,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control" - data-boundary="viewport" - data-original-title="Marie" data-testid="award-button" - title="" + title="Marie" type="button" > <span @@ -162,10 +150,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <button class="btn award-control active" - data-boundary="viewport" - data-original-title="You" data-testid="award-button" - title="" + title="You" type="button" > <span @@ -193,9 +179,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <button aria-label="Add reaction" class="award-control btn js-add-award js-test-add-button-class" - data-boundary="viewport" - data-original-title="Add reaction" - title="" + title="Add reaction" type="button" > <span diff --git a/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap index 5ab159a5a84..ca9d4488870 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap @@ -5,11 +5,11 @@ exports[`File row header component adds multiple ellipsises after 40 characters class="file-row-header bg-white sticky-top p-2 js-file-row-header" title="app/assets/javascripts/merge_requests/widget/diffs/notes" > - <span + <gl-truncate-stub class="bold" - > - app/assets/javascripts/…/…/diffs/notes - </span> + position="middle" + text="app/assets/javascripts/merge_requests/widget/diffs/notes" + /> </div> `; @@ -18,11 +18,11 @@ exports[`File row header component renders file path 1`] = ` class="file-row-header bg-white sticky-top p-2 js-file-row-header" title="app/assets" > - <span + <gl-truncate-stub class="bold" - > - app/assets - </span> + position="middle" + text="app/assets" + /> </div> `; @@ -31,10 +31,10 @@ exports[`File row header component trucates path after 40 characters 1`] = ` class="file-row-header bg-white sticky-top p-2 js-file-row-header" title="app/assets/javascripts/merge_requests" > - <span + <gl-truncate-stub class="bold" - > - app/assets/javascripts/merge_requests - </span> + position="middle" + text="app/assets/javascripts/merge_requests" + /> </div> `; diff --git a/spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap new file mode 100644 index 00000000000..df0fcf5da1c --- /dev/null +++ b/spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IntegrationHelpText component should not render the link when start and end is not provided 1`] = ` +<span> + Click nowhere! +</span> +`; + +exports[`IntegrationHelpText component should render the help text 1`] = ` +<span> + Click + <gl-link-stub + href="http://bar.com" + target="_blank" + > + + Bar + + <gl-icon-stub + class="gl-vertical-align-middle" + name="external-link" + size="12" + /> + </gl-link-stub> + ! +</span> +`; diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js index dff307e92c2..ef7815f9e9e 100644 --- a/spec/frontend/vue_shared/components/alert_details_table_spec.js +++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js @@ -23,14 +23,10 @@ const environmentPath = '/fake/path'; describe('AlertDetails', () => { let environmentData = { name: environmentName, path: environmentPath }; - let glFeatures = { exposeEnvironmentPathInAlertDetails: false }; let wrapper; function mountComponent(propsData = {}) { wrapper = mount(AlertDetailsTable, { - provide: { - glFeatures, - }, propsData: { alert: { ...mockAlert, @@ -97,34 +93,19 @@ describe('AlertDetails', () => { expect(findTableField(fields, 'Severity').exists()).toBe(true); expect(findTableField(fields, 'Status').exists()).toBe(true); expect(findTableField(fields, 'Hosts').exists()).toBe(true); - expect(findTableField(fields, 'Environment').exists()).toBe(false); + expect(findTableField(fields, 'Environment').exists()).toBe(true); }); - it('should not show disallowed and flaggedAllowed alert fields', () => { + it('should not show disallowed alert fields', () => { const fields = findTableKeys(); expect(findTableField(fields, 'Typename').exists()).toBe(false); expect(findTableField(fields, 'Todos').exists()).toBe(false); expect(findTableField(fields, 'Notes').exists()).toBe(false); expect(findTableField(fields, 'Assignees').exists()).toBe(false); - expect(findTableField(fields, 'Environment').exists()).toBe(false); - }); - }); - - describe('when exposeEnvironmentPathInAlertDetails is enabled', () => { - beforeEach(() => { - glFeatures = { exposeEnvironmentPathInAlertDetails: true }; - mountComponent(); - }); - - it('should show flaggedAllowed alert fields', () => { - const fields = findTableKeys(); - - expect(findTableField(fields, 'Environment').exists()).toBe(true); }); it('should display only the name for the environment', () => { - expect(findTableFieldValueByKey('Iid').text()).toBe('1527542'); expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName); }); diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 0abb72ace2e..63fc8a5749d 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -62,7 +62,7 @@ describe('vue_shared/components/awards_list', () => { findAwardButtons().wrappers.map(x => { return { classes: x.classes(), - title: x.attributes('data-original-title'), + title: x.attributes('title'), html: x.find('[data-testid="award-html"]').element.innerHTML, count: Number(x.find('.js-counter').text()), }; diff --git a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap index 4909d2d4226..023895099b1 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap +++ b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap @@ -59,7 +59,7 @@ exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = ` class="code highlight" > <code - id="blob-code-content" + data-blob-hash="foo-bar" > <span id="LC1" diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js index 79195aa1350..8434fdaccde 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js @@ -5,9 +5,13 @@ import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/const describe('Blob Simple Viewer component', () => { let wrapper; const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`; + const blobHash = 'foo-bar'; function createComponent(content = contentMock) { wrapper = shallowMount(SimpleViewer, { + provide: { + blobHash, + }, propsData: { content, type: 'text', diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js index 8456ca9d125..96ccf56cbc6 100644 --- a/spec/frontend/vue_shared/components/confirm_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js @@ -62,7 +62,7 @@ describe('vue_shared/components/confirm_modal', () => { wrapper.vm.modalAttributes = MOCK_MODAL_DATA.modalAttributes; }); - it('renders GlModal wtih data', () => { + it('renders GlModal with data', () => { expect(findModal().exists()).toBeTruthy(); expect(findModal().attributes()).toEqual( expect.objectContaining({ @@ -72,6 +72,24 @@ describe('vue_shared/components/confirm_modal', () => { ); }); }); + + describe.each` + desc | attrs | expectation + ${'when message is simple text'} | ${{}} | ${`<div>${MOCK_MODAL_DATA.modalAttributes.message}</div>`} + ${'when message has html'} | ${{ messageHtml: '<p>Header</p><ul onhover="alert(1)"><li>First</li></ul>' }} | ${'<p>Header</p><ul><li>First</li></ul>'} + `('$desc', ({ attrs, expectation }) => { + beforeEach(() => { + createComponent(); + wrapper.vm.modalAttributes = { + ...MOCK_MODAL_DATA.modalAttributes, + ...attrs, + }; + }); + + it('renders message', () => { + expect(findForm().element.innerHTML).toContain(expectation); + }); + }); }); describe('methods', () => { diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js index 892a96b76fd..08e5d828b8f 100644 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js @@ -60,10 +60,9 @@ describe('DropdownButtonComponent', () => { }); it('renders dropdown button icon', () => { - const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa'); + const dropdownIconEl = vm.$el.querySelector('[data-testid="chevron-down-icon"]'); expect(dropdownIconEl).not.toBeNull(); - expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); }); it('renders slot, if default slot exists', () => { diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index d28c35d26bf..bd6a18bf704 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import FileRow from '~/vue_shared/components/file_row.vue'; import FileHeader from '~/vue_shared/components/file_row_header.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; import { escapeFileUrl } from '~/lib/utils/url_utility'; describe('File row component', () => { @@ -151,4 +152,18 @@ describe('File row component', () => { expect(wrapper.find('.file-row-name').classes()).toContain('font-weight-bold'); }); + + it('renders submodule icon', () => { + const submodule = true; + + createComponent({ + file: { + ...file(), + submodule, + }, + level: 0, + }); + + expect(wrapper.find(FileIcon).props('submodule')).toBe(submodule); + }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index c79880d4766..64bfff3dfa1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -1,5 +1,12 @@ import { shallowMount, mount } from '@vue/test-utils'; -import { GlFilteredSearch, GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { + GlFilteredSearch, + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownItem, + GlFormCheckbox, +} from '@gitlab/ui'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; @@ -30,6 +37,8 @@ const createComponent = ({ recentSearchesStorageKey = 'requirements', tokens = mockAvailableTokens, sortOptions, + showCheckbox = false, + checkboxChecked = false, searchInputPlaceholder = 'Filter requirements', } = {}) => { const mountMethod = shallow ? shallowMount : mount; @@ -40,6 +49,8 @@ const createComponent = ({ recentSearchesStorageKey, tokens, sortOptions, + showCheckbox, + checkboxChecked, searchInputPlaceholder, }, }); @@ -364,6 +375,26 @@ describe('FilteredSearchBarRoot', () => { expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems); }); + it('renders checkbox when `showCheckbox` prop is true', async () => { + let wrapperWithCheckbox = createComponent({ + showCheckbox: true, + }); + + expect(wrapperWithCheckbox.find(GlFormCheckbox).exists()).toBe(true); + expect(wrapperWithCheckbox.find(GlFormCheckbox).attributes('checked')).not.toBeDefined(); + + wrapperWithCheckbox.destroy(); + + wrapperWithCheckbox = createComponent({ + showCheckbox: true, + checkboxChecked: true, + }); + + expect(wrapperWithCheckbox.find(GlFormCheckbox).attributes('checked')).toBe('true'); + + wrapperWithCheckbox.destroy(); + }); + it('renders search history items dropdown with formatting done using token symbols', async () => { const wrapperFullMount = createComponent({ sortOptions: mockSortOptions, shallow: false }); wrapperFullMount.vm.recentSearchesStore.addRecentSearch(mockHistoryItems[0]); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 72840ce381f..3fd1d8b7f42 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -45,6 +45,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index 12b7fd58670..5b7f7d242e9 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -45,6 +45,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index 3feb05bab35..74172db81c2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -50,6 +50,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index 0ec814e3f15..67f9a9c70cc 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -48,6 +48,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', }, stubs, }); @@ -120,7 +121,9 @@ describe('MilestoneToken', () => { wrapper.vm.fetchMilestoneBySearchTerm('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith('There was a problem fetching milestones.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching milestones.', + }); }); }); diff --git a/spec/frontend/vue_shared/components/integration_help_text_spec.js b/spec/frontend/vue_shared/components/integration_help_text_spec.js new file mode 100644 index 00000000000..4269d36d0e2 --- /dev/null +++ b/spec/frontend/vue_shared/components/integration_help_text_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; + +describe('IntegrationHelpText component', () => { + let wrapper; + const defaultProps = { + message: 'Click %{linkStart}Bar%{linkEnd}!', + messageUrl: 'http://bar.com', + }; + + function createComponent(props = {}) { + return shallowMount(IntegrationHelpText, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should use the gl components', () => { + wrapper = createComponent(); + + expect(wrapper.find(GlSprintf).exists()).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(true); + expect(wrapper.find(GlLink).exists()).toBe(true); + }); + + it('should render the help text', () => { + wrapper = createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should not use the gl-link and gl-icon components', () => { + wrapper = createComponent({ message: 'Click nowhere!' }); + + expect(wrapper.find(GlSprintf).exists()).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(false); + expect(wrapper.find(GlLink).exists()).toBe(false); + }); + + it('should not render the link when start and end is not provided', () => { + wrapper = createComponent({ message: 'Click nowhere!' }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js index efa9b5796fb..464fe3411dd 100644 --- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js +++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js @@ -239,4 +239,30 @@ describe('Local Storage Sync', () => { }); }); }); + + it('clears localStorage when clear property is true', async () => { + const storageKey = 'key'; + const value = 'initial'; + + createComponent({ + props: { + storageKey, + }, + }); + wrapper.setProps({ + value, + }); + + await wrapper.vm.$nextTick(); + + expect(localStorage.getItem(storageKey)).toBe(value); + + wrapper.setProps({ + clear: true, + }); + + await wrapper.vm.$nextTick(); + + expect(localStorage.getItem(storageKey)).toBe(null); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index b19e74b5b11..c0a000690f8 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -29,6 +29,10 @@ describe('Suggestion Diff component', () => { }); }; + beforeEach(() => { + window.gon.current_user_id = 1; + }); + afterEach(() => { wrapper.destroy(); }); @@ -71,6 +75,14 @@ describe('Suggestion Diff component', () => { expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true); }); + it('does not render apply suggestion button with anonymous user', () => { + window.gon.current_user_id = null; + + createComponent(); + + expect(findApplyButton().exists()).toBe(false); + }); + describe('when apply suggestion is clicked', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js index d7bb8c0d142..5674929716d 100644 --- a/spec/frontend/vue_shared/components/members/mock_data.js +++ b/spec/frontend/vue_shared/components/members/mock_data.js @@ -3,6 +3,7 @@ export const member = { canUpdate: false, canRemove: false, canOverride: false, + isOverridden: false, accessLevel: { integerValue: 50, stringValue: 'Owner' }, source: { id: 178, diff --git a/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js b/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js new file mode 100644 index 00000000000..a1afdbc2b49 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js @@ -0,0 +1,166 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { nextTick } from 'vue'; +import { GlDatepicker } from '@gitlab/ui'; +import { useFakeDate } from 'helpers/fake_date'; +import waitForPromises from 'helpers/wait_for_promises'; +import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue'; +import { member } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ExpirationDatepicker', () => { + // March 15th, 2020 3:00 + useFakeDate(2020, 2, 15, 3); + + let wrapper; + let actions; + let resolveUpdateMemberExpiration; + const $toast = { + show: jest.fn(), + }; + + const createStore = () => { + actions = { + updateMemberExpiration: jest.fn( + () => + new Promise(resolve => { + resolveUpdateMemberExpiration = resolve; + }), + ), + }; + + return new Vuex.Store({ actions }); + }; + + const createComponent = (propsData = {}) => { + wrapper = mount(ExpirationDatepicker, { + propsData: { + member, + permissions: { canUpdate: true }, + ...propsData, + }, + localVue, + store: createStore(), + mocks: { + $toast, + }, + }); + }; + + const findInput = () => wrapper.find('input'); + const findDatepicker = () => wrapper.find(GlDatepicker); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('datepicker input', () => { + it('sets `member.expiresAt` as initial date', async () => { + createComponent({ member: { ...member, expiresAt: '2020-03-17T00:00:00Z' } }); + + await nextTick(); + + expect(findInput().element.value).toBe('2020-03-17'); + }); + }); + + describe('props', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets `minDate` prop as tomorrow', () => { + expect( + findDatepicker() + .props('minDate') + .toISOString(), + ).toBe(new Date('2020-3-16').toISOString()); + }); + + it('sets `target` prop as `null` so datepicker opens on focus', () => { + expect(findDatepicker().props('target')).toBe(null); + }); + + it("sets `container` prop as `null` so table styles don't affect the datepicker styles", () => { + expect(findDatepicker().props('container')).toBe(null); + }); + + it('shows clear button', () => { + expect(findDatepicker().props('showClearButton')).toBe(true); + }); + }); + + describe('when datepicker is changed', () => { + beforeEach(async () => { + createComponent(); + + findDatepicker().vm.$emit('input', new Date('2020-03-17')); + }); + + it('calls `updateMemberExpiration` Vuex action', () => { + expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), { + memberId: member.id, + expiresAt: new Date('2020-03-17'), + }); + }); + + it('displays toast when successful', async () => { + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Expiration date updated successfully.'); + }); + + it('disables dropdown while waiting for `updateMemberExpiration` to resolve', async () => { + expect(findDatepicker().props('disabled')).toBe(true); + + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect(findDatepicker().props('disabled')).toBe(false); + }); + }); + + describe('when datepicker is cleared', () => { + beforeEach(async () => { + createComponent(); + + findInput().setValue('2020-03-17'); + await nextTick(); + wrapper.find('[data-testid="clear-button"]').trigger('click'); + }); + + it('calls `updateMemberExpiration` Vuex action', () => { + expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), { + memberId: member.id, + expiresAt: null, + }); + }); + + it('displays toast when successful', async () => { + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Expiration date removed successfully.'); + }); + + it('disables datepicker while waiting for `updateMemberExpiration` to resolve', async () => { + expect(findDatepicker().props('disabled')).toBe(true); + + resolveUpdateMemberExpiration(); + await waitForPromises(); + + expect(findDatepicker().props('disabled')).toBe(false); + }); + }); + + describe('when user does not have `canUpdate` permissions', () => { + it('disables datepicker', () => { + createComponent({ permissions: { canUpdate: false } }); + + expect(findDatepicker().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js index 20c1c26d2ee..e593e88438c 100644 --- a/spec/frontend/vue_shared/components/members/table/members_table_spec.js +++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js @@ -3,14 +3,16 @@ import Vuex from 'vuex'; import { getByText as getByTextHelper, getByTestId as getByTestIdHelper, + within, } from '@testing-library/dom'; -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlTable } from '@gitlab/ui'; import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; +import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue'; import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue'; import * as initUserPopovers from '~/user_popovers'; import { member as memberMock, invite, accessRequest } from '../mock_data'; @@ -26,7 +28,12 @@ describe('MemberList', () => { state: { members: [], tableFields: [], + tableAttrs: { + table: { 'data-qa-selector': 'members_list' }, + tr: { 'data-qa-selector': 'member_row' }, + }, sourceId: 1, + currentUserId: 1, ...state, }, }); @@ -44,6 +51,7 @@ describe('MemberList', () => { 'member-action-buttons', 'role-dropdown', 'remove-group-link-modal', + 'expiration-datepicker', ], }); }; @@ -54,18 +62,24 @@ describe('MemberList', () => { const getByTestId = (id, options) => createWrapper(getByTestIdHelper(wrapper.element, id, options)); + const findTable = () => wrapper.find(GlTable); + afterEach(() => { wrapper.destroy(); wrapper = null; }); describe('fields', () => { - const memberCanUpdate = { + const directMember = { ...memberMock, - canUpdate: true, source: { ...memberMock.source, id: 1 }, }; + const memberCanUpdate = { + ...directMember, + canUpdate: true, + }; + it.each` field | label | member | expectedComponent ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} @@ -75,7 +89,7 @@ describe('MemberList', () => { ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} - ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} `('renders the $label field', ({ field, label, member, expectedComponent }) => { createComponent({ members: [member], @@ -94,19 +108,60 @@ describe('MemberList', () => { } }); - it('renders "Actions" field for screen readers', () => { - createComponent({ members: [memberMock], tableFields: ['actions'] }); + describe('"Actions" field', () => { + it('renders "Actions" field for screen readers', () => { + createComponent({ members: [memberCanUpdate], tableFields: ['actions'] }); - const actionField = getByTestId('col-actions'); + const actionField = getByTestId('col-actions'); - expect(actionField.exists()).toBe(true); - expect(actionField.classes('gl-sr-only')).toBe(true); - expect( - wrapper - .find(`[data-label="Actions"][role="cell"]`) - .find(MemberActionButtons) - .exists(), - ).toBe(true); + expect(actionField.exists()).toBe(true); + expect(actionField.classes('gl-sr-only')).toBe(true); + expect( + wrapper + .find(`[data-label="Actions"][role="cell"]`) + .find(MemberActionButtons) + .exists(), + ).toBe(true); + }); + + describe('when user is not logged in', () => { + it('does not render the "Actions" field', () => { + createComponent({ currentUserId: null, tableFields: ['actions'] }); + + expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + }); + }); + + const memberCanRemove = { + ...directMember, + canRemove: true, + }; + + describe.each` + permission | members + ${'canUpdate'} | ${[memberCanUpdate]} + ${'canRemove'} | ${[memberCanRemove]} + ${'canResend'} | ${[invite]} + `('when one of the members has $permission permissions', ({ members }) => { + it('renders the "Actions" field', () => { + createComponent({ members, tableFields: ['actions'] }); + + expect(getByTestId('col-actions').exists()).toBe(true); + }); + }); + + describe.each` + permission | members + ${'canUpdate'} | ${[memberMock]} + ${'canRemove'} | ${[memberMock]} + ${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]} + `('when none of the members have $permission permissions', ({ members }) => { + it('does not render the "Actions" field', () => { + createComponent({ members, tableFields: ['actions'] }); + + expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + }); + }); }); }); @@ -138,4 +193,20 @@ describe('MemberList', () => { expect(initUserPopoversMock).toHaveBeenCalled(); }); + + it('adds QA selector to table', () => { + createComponent(); + + expect(findTable().attributes('data-qa-selector')).toBe('members_list'); + }); + + it('adds QA selector to table row', () => { + createComponent(); + + expect( + findTable() + .find('tbody tr') + .attributes('data-qa-selector'), + ).toBe('member_row'); + }); }); diff --git a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js index 1e47953a510..55ec7000693 100644 --- a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js @@ -30,6 +30,7 @@ describe('RoleDropdown', () => { wrapper = mount(RoleDropdown, { propsData: { member, + permissions: {}, ...propsData, }, localVue, @@ -115,11 +116,11 @@ describe('RoleDropdown', () => { await nextTick(); - expect(findDropdown().attributes('disabled')).toBe('disabled'); + expect(findDropdown().props('disabled')).toBe(true); await waitForPromises(); - expect(findDropdown().attributes('disabled')).toBeUndefined(); + expect(findDropdown().props('disabled')).toBe(false); }); }); }); diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js index f183abc08d6..3f2b2097133 100644 --- a/spec/frontend/vue_shared/components/members/utils_spec.js +++ b/spec/frontend/vue_shared/components/members/utils_spec.js @@ -1,5 +1,19 @@ -import { generateBadges } from '~/vue_shared/components/members/utils'; -import { member as memberMock } from './mock_data'; +import { + generateBadges, + isGroup, + isDirectMember, + isCurrentUser, + canRemove, + canResend, + canUpdate, + canOverride, +} from '~/vue_shared/components/members/utils'; +import { member as memberMock, group, invite } from './mock_data'; + +const DIRECT_MEMBER_ID = 178; +const INHERITED_MEMBER_ID = 179; +const IS_CURRENT_USER_ID = 123; +const IS_NOT_CURRENT_USER_ID = 124; describe('Members Utils', () => { describe('generateBadges', () => { @@ -26,4 +40,83 @@ describe('Members Utils', () => { expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected)); }); }); + + describe('isGroup', () => { + test.each` + member | expected + ${group} | ${true} + ${memberMock} | ${false} + `('returns $expected', ({ member, expected }) => { + expect(isGroup(member)).toBe(expected); + }); + }); + + describe('isDirectMember', () => { + test.each` + sourceId | expected + ${DIRECT_MEMBER_ID} | ${true} + ${INHERITED_MEMBER_ID} | ${false} + `('returns $expected', ({ sourceId, expected }) => { + expect(isDirectMember(memberMock, sourceId)).toBe(expected); + }); + }); + + describe('isCurrentUser', () => { + test.each` + currentUserId | expected + ${IS_CURRENT_USER_ID} | ${true} + ${IS_NOT_CURRENT_USER_ID} | ${false} + `('returns $expected', ({ currentUserId, expected }) => { + expect(isCurrentUser(memberMock, currentUserId)).toBe(expected); + }); + }); + + describe('canRemove', () => { + const memberCanRemove = { + ...memberMock, + canRemove: true, + }; + + test.each` + member | sourceId | expected + ${memberCanRemove} | ${DIRECT_MEMBER_ID} | ${true} + ${memberCanRemove} | ${INHERITED_MEMBER_ID} | ${false} + ${memberMock} | ${INHERITED_MEMBER_ID} | ${false} + `('returns $expected', ({ member, sourceId, expected }) => { + expect(canRemove(member, sourceId)).toBe(expected); + }); + }); + + describe('canResend', () => { + test.each` + member | expected + ${invite} | ${true} + ${{ ...invite, invite: { ...invite.invite, canResend: false } }} | ${false} + `('returns $expected', ({ member, sourceId, expected }) => { + expect(canResend(member, sourceId)).toBe(expected); + }); + }); + + describe('canUpdate', () => { + const memberCanUpdate = { + ...memberMock, + canUpdate: true, + }; + + test.each` + member | currentUserId | sourceId | expected + ${memberCanUpdate} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${true} + ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false} + ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${INHERITED_MEMBER_ID} | ${false} + ${memberMock} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false} + `('returns $expected', ({ member, currentUserId, sourceId, expected }) => { + expect(canUpdate(member, currentUserId, sourceId)).toBe(expected); + }); + }); + + describe('canOverride', () => { + it('returns `false`', () => { + expect(canOverride(memberMock)).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/modal_copy_button_spec.js b/spec/frontend/vue_shared/components/modal_copy_button_spec.js index e5a8860f42e..ca9f8ff54d4 100644 --- a/spec/frontend/vue_shared/components/modal_copy_button_spec.js +++ b/spec/frontend/vue_shared/components/modal_copy_button_spec.js @@ -1,9 +1,7 @@ -import Vue from 'vue'; -import { shallowMount } from '@vue/test-utils'; -import modalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { shallowMount, createWrapper } from '@vue/test-utils'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; describe('modal copy button', () => { - const Component = Vue.extend(modalCopyButton); let wrapper; afterEach(() => { @@ -11,16 +9,18 @@ describe('modal copy button', () => { }); beforeEach(() => { - wrapper = shallowMount(Component, { + wrapper = shallowMount(ModalCopyButton, { propsData: { text: 'copy me', title: 'Copy this value', + id: 'test-id', }, }); }); describe('clipboard', () => { it('should fire a `success` event on click', () => { + const root = createWrapper(wrapper.vm.$root); document.execCommand = jest.fn(() => true); window.getSelection = jest.fn(() => ({ toString: jest.fn(() => 'test'), @@ -31,6 +31,7 @@ describe('modal copy button', () => { return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted().success).not.toBeEmpty(); expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(root.emitted('bv::hide::tooltip')).toEqual([['test-id']]); }); }); it("should propagate the clipboard error event if execCommand doesn't work", () => { diff --git a/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js new file mode 100644 index 00000000000..233c488b60b --- /dev/null +++ b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js @@ -0,0 +1,31 @@ +import { shallowMount } from '@vue/test-utils'; +import { getByText } from '@testing-library/dom'; +import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; + +describe('MultiSelectDropdown Component', () => { + it('renders items slot', () => { + const wrapper = shallowMount(MultiSelectDropdown, { + propsData: { + text: '', + headerText: '', + }, + slots: { + items: '<p>Test</p>', + }, + }); + expect(getByText(wrapper.element, 'Test')).toBeDefined(); + }); + + it('renders search slot', () => { + const wrapper = shallowMount(MultiSelectDropdown, { + propsData: { + text: '', + headerText: '', + }, + slots: { + search: '<p>Search</p>', + }, + }); + expect(getByText(wrapper.element, 'Search')).toBeDefined(); + }); +}); diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index d943aaf3e5f..0f7c8e97635 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -70,7 +70,7 @@ describe('AlertManagementEmptyState', () => { ...props, }, slots: { - 'emtpy-state': EmptyStateSlot, + 'empty-state': EmptyStateSlot, 'header-actions': HeaderActionsSlot, title: TitleSlot, table: TableSlot, diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js index 5cb606b58d9..b743a663f06 100644 --- a/spec/frontend/vue_shared/components/registry/title_area_spec.js +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -5,12 +5,16 @@ import component from '~/vue_shared/components/registry/title_area.vue'; describe('title area', () => { let wrapper; + const DYNAMIC_SLOT = 'metadata-dynamic-slot'; + const findSubHeaderSlot = () => wrapper.find('[data-testid="sub-header"]'); const findRightActionsSlot = () => wrapper.find('[data-testid="right-actions"]'); const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`); const findTitle = () => wrapper.find('[data-testid="title"]'); const findAvatar = () => wrapper.find(GlAvatar); const findInfoMessages = () => wrapper.findAll('[data-testid="info-message"]'); + const findDynamicSlot = () => wrapper.find(`[data-testid="${DYNAMIC_SLOT}`); + const findSlotOrderElements = () => wrapper.findAll('[slot-test]'); const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => { wrapper = shallowMount(component, { @@ -98,6 +102,59 @@ describe('title area', () => { }); }); + describe('dynamic slots', () => { + const createDynamicSlot = () => { + return wrapper.vm.$createElement('div', { + attrs: { + 'data-testid': DYNAMIC_SLOT, + 'slot-test': true, + }, + }); + }; + it('shows dynamic slots', async () => { + mountComponent(); + // we manually add a new slot to simulate dynamic slots being evaluated after the initial mount + wrapper.vm.$slots[DYNAMIC_SLOT] = createDynamicSlot(); + + await wrapper.vm.$nextTick(); + expect(findDynamicSlot().exists()).toBe(false); + + await wrapper.vm.$nextTick(); + expect(findDynamicSlot().exists()).toBe(true); + }); + + it('preserve the order of the slots', async () => { + mountComponent({ + slots: { + 'metadata-foo': '<div slot-test data-testid="metadata-foo"></div>', + }, + }); + + // rewrite slot putting dynamic slot as first + wrapper.vm.$slots = { + 'metadata-dynamic-slot': createDynamicSlot(), + 'metadata-foo': wrapper.vm.$slots['metadata-foo'], + }; + + await wrapper.vm.$nextTick(); + expect(findDynamicSlot().exists()).toBe(false); + expect(findMetadataSlot('metadata-foo').exists()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect( + findSlotOrderElements() + .at(0) + .attributes('data-testid'), + ).toBe(DYNAMIC_SLOT); + expect( + findSlotOrderElements() + .at(1) + .attributes('data-testid'), + ).toBe('metadata-foo'); + }); + }); + describe('info-messages', () => { it('shows a message when the props contains one', () => { mountComponent({ propsData: { infoMessages: [{ text: 'foo foo bar bar' }] } }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js index 0f2f263a776..d79df4d0557 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -91,12 +91,25 @@ describe('Editor Service', () => { }); describe('addImage', () => { - it('calls the exec method on the instance', () => { - const mockImage = { imageUrl: 'some/url.png', description: 'some description' }; + const file = new File([], 'some-file.jpg'); + const mockImage = { imageUrl: 'some/url.png', altText: 'some alt text' }; - addImage(mockInstance, mockImage); + it('calls the insertElement method on the squire instance when in WYSIWYG mode', () => { + jest.spyOn(URL, 'createObjectURL'); + mockInstance.editor.isWysiwygMode.mockReturnValue(true); + mockInstance.editor.getSquire.mockReturnValue({ insertElement: jest.fn() }); - expect(mockInstance.editor.exec).toHaveBeenCalledWith('AddImage', mockImage); + addImage(mockInstance, mockImage, file); + + expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalled(); + expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(file); + }); + + it('calls the insertText method on the instance when in Markdown mode', () => { + mockInstance.editor.isWysiwygMode.mockReturnValue(false); + addImage(mockInstance, mockImage, file); + + expect(mockInstance.editor.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)'); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js index 0c2ac53aa52..16370a7aaad 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js @@ -15,10 +15,7 @@ describe('Add Image Modal', () => { const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); beforeEach(() => { - wrapper = shallowMount(AddImageModal, { - provide: { glFeatures: { sseImageUploads: true } }, - propsData, - }); + wrapper = shallowMount(AddImageModal, { propsData }); }); describe('when content is loaded', () => { diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index 8c2c0413819..d50cf2915e8 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -180,7 +180,7 @@ describe('Rich Content Editor', () => { wrapper.vm.$refs.editor = mockInstance; findAddImageModal().vm.$emit('addImage', mockImage); - expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage); + expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage, undefined); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js index fd745c21bb6..85516eae4cf 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -189,4 +189,30 @@ describe('rich_content_editor/services/html_to_markdown_renderer', () => { expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult); }); }); + + describe('IMG', () => { + const originalSrc = 'path/to/image.png'; + const alt = 'alt text'; + let node; + + beforeEach(() => { + node = document.createElement('img'); + node.alt = alt; + node.src = originalSrc; + }); + + it('returns an image with its original src of the `original-src` attribute is preset', () => { + node.dataset.originalSrc = originalSrc; + node.src = 'modified/path/to/image.png'; + + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + + expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); + }); + + it('fallback to `src` if no `original-src` is specified on the image', () => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js new file mode 100644 index 00000000000..01f7f3d49c7 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js @@ -0,0 +1,107 @@ +export const mockGraphqlRunnerPlatforms = { + data: { + runnerPlatforms: { + nodes: [ + { + name: 'linux', + humanReadableName: 'Linux', + architectures: { + nodes: [ + { + name: 'amd64', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64', + __typename: 'RunnerArchitecture', + }, + { + name: '386', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-386', + __typename: 'RunnerArchitecture', + }, + { + name: 'arm', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm', + __typename: 'RunnerArchitecture', + }, + { + name: 'arm64', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm64', + __typename: 'RunnerArchitecture', + }, + ], + __typename: 'RunnerArchitectureConnection', + }, + __typename: 'RunnerPlatform', + }, + { + name: 'osx', + humanReadableName: 'macOS', + architectures: { + nodes: [ + { + name: 'amd64', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64', + __typename: 'RunnerArchitecture', + }, + ], + __typename: 'RunnerArchitectureConnection', + }, + __typename: 'RunnerPlatform', + }, + { + name: 'windows', + humanReadableName: 'Windows', + architectures: { + nodes: [ + { + name: 'amd64', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe', + __typename: 'RunnerArchitecture', + }, + { + name: '386', + downloadLocation: + 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-386.exe', + __typename: 'RunnerArchitecture', + }, + ], + __typename: 'RunnerArchitectureConnection', + }, + __typename: 'RunnerPlatform', + }, + { + name: 'docker', + humanReadableName: 'Docker', + architectures: null, + __typename: 'RunnerPlatform', + }, + { + name: 'kubernetes', + humanReadableName: 'Kubernetes', + architectures: null, + __typename: 'RunnerPlatform', + }, + ], + __typename: 'RunnerPlatformConnection', + }, + project: { id: 'gid://gitlab/Project/1', __typename: 'Project' }, + group: null, + }, +}; + +export const mockGraphqlInstructions = { + data: { + runnerSetup: { + installInstructions: + "# Download the binary for your system\nsudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64\n\n# Give it permissions to execute\nsudo chmod +x /usr/local/bin/gitlab-runner\n\n# Create a GitLab CI user\nsudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash\n\n# Install and run as service\nsudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner\nsudo gitlab-runner start\n", + registerInstructions: + 'sudo gitlab-runner register --url http://192.168.1.81:3000/ --registration-token GE5gsjeep_HAtBf9s3Yz', + __typename: 'RunnerSetup', + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js new file mode 100644 index 00000000000..afbcee506c7 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js @@ -0,0 +1,119 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; +import getRunnerPlatforms from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructions from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql'; + +import { mockGraphqlRunnerPlatforms, mockGraphqlInstructions } from './mock_data'; + +const projectPath = 'gitlab-org/gitlab'; +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('RunnerInstructions component', () => { + let wrapper; + let fakeApollo; + + const findModalButton = () => wrapper.find('[data-testid="show-modal-button"]'); + const findPlatformButtons = () => wrapper.findAll('[data-testid="platform-button"]'); + const findArchitectureDropdownItems = () => + wrapper.findAll('[data-testid="architecture-dropdown-item"]'); + const findBinaryInstructionsSection = () => wrapper.find('[data-testid="binary-instructions"]'); + const findRunnerInstructionsSection = () => wrapper.find('[data-testid="runner-instructions"]'); + + beforeEach(() => { + const requestHandlers = [ + [getRunnerPlatforms, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], + [getRunnerSetupInstructions, jest.fn().mockResolvedValue(mockGraphqlInstructions)], + ]; + + fakeApollo = createMockApollo(requestHandlers); + + wrapper = shallowMount(RunnerInstructions, { + provide: { + projectPath, + }, + localVue, + apolloProvider: fakeApollo, + }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should show the "Show Runner installation instructions" button', () => { + const button = findModalButton(); + + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Show Runner installation instructions'); + }); + + it('should contain a number of platforms buttons', () => { + const buttons = findPlatformButtons(); + + expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length); + }); + + it('should contain a number of dropdown items for the architecture options', () => { + const platformButton = findPlatformButtons().at(0); + platformButton.vm.$emit('click'); + + return wrapper.vm.$nextTick(() => { + const dropdownItems = findArchitectureDropdownItems(); + + expect(dropdownItems).toHaveLength( + mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length, + ); + }); + }); + + it('should display the binary installation instructions for a selected architecture', async () => { + const platformButton = findPlatformButtons().at(0); + platformButton.vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const dropdownItem = findArchitectureDropdownItems().at(0); + dropdownItem.vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const runner = findBinaryInstructionsSection(); + + expect(runner.text()).toEqual( + expect.stringContaining('sudo chmod +x /usr/local/bin/gitlab-runner'), + ); + expect(runner.text()).toEqual( + expect.stringContaining( + `sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash`, + ), + ); + expect(runner.text()).toEqual( + expect.stringContaining( + 'sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner', + ), + ); + expect(runner.text()).toEqual(expect.stringContaining('sudo gitlab-runner start')); + }); + + it('should display the runner register instructions for a selected architecture', async () => { + const platformButton = findPlatformButtons().at(0); + platformButton.vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const dropdownItem = findArchitectureDropdownItems().at(0); + dropdownItem.vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const runner = findRunnerInstructionsSection(); + + expect(runner.text()).toEqual( + expect.stringContaining(mockGraphqlInstructions.data.runnerSetup.registerInstructions), + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js new file mode 100644 index 00000000000..a97e26caf53 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js @@ -0,0 +1,375 @@ +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { + GlIcon, + GlLoadingIcon, + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlSearchBoxByType, + GlButton, +} from '@gitlab/ui'; + +import axios from '~/lib/utils/axios_utils'; +import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue'; + +const mockProjects = [ + { + id: 2, + name_with_namespace: 'Gitlab Org / Gitlab Shell', + full_path: 'gitlab-org/gitlab-shell', + }, + { + id: 3, + name_with_namespace: 'Gnuwget / Wget2', + full_path: 'gnuwget/wget2', + }, + { + id: 4, + name_with_namespace: 'Commit451 / Lab Coat', + full_path: 'Commit451/lab-coat', + }, +]; + +const mockProps = { + projectsFetchPath: '/-/autocomplete/projects?project_id=1', + dropdownButtonTitle: 'Move issuable', + dropdownHeaderTitle: 'Move issuable', + moveInProgress: false, +}; + +const mockEvent = { + stopPropagation: jest.fn(), + preventDefault: jest.fn(), +}; + +const createComponent = (propsData = mockProps) => + shallowMount(IssuableMoveDropdown, { + propsData, + }); + +describe('IssuableMoveDropdown', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + wrapper.vm.$refs.dropdown.hide = jest.fn(); + wrapper.vm.$refs.searchInput.focusInput = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('watch', () => { + describe('searchKey', () => { + it('calls `fetchProjects` with value of the prop', async () => { + jest.spyOn(wrapper.vm, 'fetchProjects'); + wrapper.setData({ + searchKey: 'foo', + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.fetchProjects).toHaveBeenCalledWith('foo'); + }); + }); + }); + + describe('methods', () => { + describe('fetchProjects', () => { + it('sets projectsListLoading to true and projectsListLoadFailed to false', () => { + wrapper.vm.fetchProjects(); + + expect(wrapper.vm.projectsListLoading).toBe(true); + expect(wrapper.vm.projectsListLoadFailed).toBe(false); + }); + + it('calls `axios.get` with `projectsFetchPath` and query param `search`', () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + wrapper.vm.fetchProjects('foo'); + + expect(axios.get).toHaveBeenCalledWith( + mockProps.projectsFetchPath, + expect.objectContaining({ + params: { + search: 'foo', + }, + }), + ); + }); + + it('sets response to `projects` and focuses on searchInput when request is successful', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + await wrapper.vm.fetchProjects('foo'); + + expect(wrapper.vm.projects).toBe(mockProjects); + expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled(); + }); + + it('sets projectsListLoadFailed to true when request fails', async () => { + jest.spyOn(axios, 'get').mockRejectedValue({}); + + await wrapper.vm.fetchProjects('foo'); + + expect(wrapper.vm.projectsListLoadFailed).toBe(true); + }); + + it('sets projectsListLoading to false when request completes', async () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + await wrapper.vm.fetchProjects('foo'); + + expect(wrapper.vm.projectsListLoading).toBe(false); + }); + }); + + describe('isSelectedProject', () => { + it.each` + project | selectedProject | title | returnValue + ${mockProjects[0]} | ${mockProjects[0]} | ${'are same projects'} | ${true} + ${mockProjects[0]} | ${mockProjects[1]} | ${'are different projects'} | ${false} + `( + 'returns $returnValue when selectedProject and provided project param $title', + async ({ project, selectedProject, returnValue }) => { + wrapper.setData({ + selectedProject, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isSelectedProject(project)).toBe(returnValue); + }, + ); + + it('returns false when selectedProject is null', async () => { + wrapper.setData({ + selectedProject: null, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isSelectedProject(mockProjects[0])).toBe(false); + }); + }); + }); + + describe('template', () => { + const findDropdownEl = () => wrapper.find(GlDropdown); + + it('renders collapsed state element with icon', () => { + const collapsedEl = wrapper.find('[data-testid="move-collapsed"]'); + + expect(collapsedEl.exists()).toBe(true); + expect(collapsedEl.attributes('title')).toBe(mockProps.dropdownButtonTitle); + expect(collapsedEl.find(GlIcon).exists()).toBe(true); + expect(collapsedEl.find(GlIcon).props('name')).toBe('arrow-right'); + }); + + describe('gl-dropdown component', () => { + it('renders component container element', () => { + expect(findDropdownEl().exists()).toBe(true); + expect(findDropdownEl().props('block')).toBe(true); + }); + + it('renders gl-dropdown-form component', () => { + expect( + findDropdownEl() + .find(GlDropdownForm) + .exists(), + ).toBe(true); + }); + + it('renders header element', () => { + const headerEl = findDropdownEl().find('[data-testid="header"]'); + + expect(headerEl.exists()).toBe(true); + expect(headerEl.find('span').text()).toBe(mockProps.dropdownHeaderTitle); + expect(headerEl.find(GlButton).props('icon')).toBe('close'); + }); + + it('renders gl-search-box-by-type component', () => { + const searchEl = findDropdownEl().find(GlSearchBoxByType); + + expect(searchEl.exists()).toBe(true); + expect(searchEl.attributes()).toMatchObject({ + placeholder: 'Search project', + debounce: '300', + }); + }); + + it('renders gl-loading-icon component when projectsListLoading prop is true', async () => { + wrapper.setData({ + projectsListLoading: true, + }); + + await wrapper.vm.$nextTick(); + + expect( + findDropdownEl() + .find(GlLoadingIcon) + .exists(), + ).toBe(true); + }); + + it('renders gl-dropdown-item components for available projects', async () => { + wrapper.setData({ + projects: mockProjects, + selectedProject: mockProjects[0], + }); + + await wrapper.vm.$nextTick(); + + const dropdownItems = wrapper.findAll(GlDropdownItem); + + expect(dropdownItems).toHaveLength(mockProjects.length); + expect(dropdownItems.at(0).props()).toMatchObject({ + isCheckItem: true, + isChecked: true, + }); + expect(dropdownItems.at(0).text()).toBe(mockProjects[0].name_with_namespace); + }); + + it('renders string "No matching results" when search does not yield any matches', async () => { + wrapper.setData({ + searchKey: 'foo', + }); + + // Wait for `searchKey` watcher to run. + await wrapper.vm.$nextTick(); + + wrapper.setData({ + projects: [], + projectsListLoading: false, + }); + + await wrapper.vm.$nextTick(); + + const dropdownContentEl = wrapper.find('[data-testid="content"]'); + + expect(dropdownContentEl.text()).toContain('No matching results'); + }); + + it('renders string "Failed to load projects" when loading projects list fails', async () => { + wrapper.setData({ + projects: [], + projectsListLoading: false, + projectsListLoadFailed: true, + }); + + await wrapper.vm.$nextTick(); + + const dropdownContentEl = wrapper.find('[data-testid="content"]'); + + expect(dropdownContentEl.text()).toContain('Failed to load projects'); + }); + + it('renders gl-button within footer', async () => { + const moveButtonEl = wrapper.find('[data-testid="footer"]').find(GlButton); + + expect(moveButtonEl.text()).toBe('Move'); + expect(moveButtonEl.attributes('disabled')).toBe('true'); + + wrapper.setData({ + selectedProject: mockProjects[0], + }); + + await wrapper.vm.$nextTick(); + + expect( + wrapper + .find('[data-testid="footer"]') + .find(GlButton) + .attributes('disabled'), + ).not.toBeDefined(); + }); + }); + + describe('events', () => { + it('collapsed state element emits `toggle-collapse` event on component when clicked', () => { + wrapper.find('[data-testid="move-collapsed"]').trigger('click'); + + expect(wrapper.emitted('toggle-collapse')).toBeTruthy(); + }); + + it('gl-dropdown component calls `fetchProjects` on `shown` event', () => { + jest.spyOn(axios, 'get').mockResolvedValue({ + data: mockProjects, + }); + + findDropdownEl().vm.$emit('shown'); + + expect(axios.get).toHaveBeenCalled(); + }); + + it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', async () => { + wrapper.setData({ + projectItemClick: true, + }); + + findDropdownEl().vm.$emit('hide', mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(wrapper.vm.projectItemClick).toBe(false); + }); + + it('gl-dropdown component emits `dropdown-close` event on component from `hide` event', async () => { + findDropdownEl().vm.$emit('hide'); + + expect(wrapper.emitted('dropdown-close')).toBeTruthy(); + }); + + it('close icon in dropdown header closes the dropdown when clicked', () => { + wrapper + .find('[data-testid="header"]') + .find(GlButton) + .vm.$emit('click', mockEvent); + + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled(); + }); + + it('sets project for clicked gl-dropdown-item to selectedProject', async () => { + wrapper.setData({ + projects: mockProjects, + }); + + await wrapper.vm.$nextTick(); + + wrapper + .findAll(GlDropdownItem) + .at(0) + .vm.$emit('click', mockEvent); + + expect(wrapper.vm.selectedProject).toBe(mockProjects[0]); + }); + + it('hides dropdown and emits `move-issuable` event when move button is clicked', async () => { + wrapper.setData({ + selectedProject: mockProjects[0], + }); + + await wrapper.vm.$nextTick(); + + wrapper + .find('[data-testid="footer"]') + .find(GlButton) + .vm.$emit('click'); + + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled(); + expect(wrapper.emitted('move-issuable')).toBeTruthy(); + expect(wrapper.emitted('move-issuable')[0]).toEqual([mockProjects[0]]); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js index 7847e0ee71d..71c040c6633 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -81,9 +81,7 @@ describe('DropdownValueCollapsedComponent', () => { describe('template', () => { it('renders component container element with tooltip`', () => { - expect(vm.$el.dataset.placement).toBe('left'); - expect(vm.$el.dataset.container).toBe('body'); - expect(vm.$el.dataset.originalTitle).toBe(vm.labelsList); + expect(vm.$el.title).toBe(vm.labelsList); }); it('renders tags icon element', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index e8a126d8774..78367b3a5b4 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -128,6 +128,16 @@ describe('DropdownContentsLabelsView', () => { }); }); + describe('handleComponentAppear', () => { + it('calls `focusInput` on searchInput field', async () => { + wrapper.vm.$refs.searchInput.focusInput = jest.fn(); + + await wrapper.vm.handleComponentAppear(); + + expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled(); + }); + }); + describe('handleComponentDisappear', () => { it('calls action `receiveLabelsSuccess` with empty array', () => { jest.spyOn(wrapper.vm, 'receiveLabelsSuccess'); @@ -301,7 +311,6 @@ describe('DropdownContentsLabelsView', () => { const searchInputEl = wrapper.find(GlSearchBoxByType); expect(searchInputEl.exists()).toBe(true); - expect(searchInputEl.attributes('autofocus')).toBe('true'); }); it('renders label elements for all labels', () => { diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js index bc86ee5a0c6..0786882f527 100644 --- a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js +++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js @@ -29,6 +29,13 @@ describe('StackedProgressBarComponent', () => { vm.$destroy(); }); + const findSuccessBarText = wrapper => wrapper.$el.querySelector('.status-green').innerText.trim(); + const findNeutralBarText = wrapper => + wrapper.$el.querySelector('.status-neutral').innerText.trim(); + const findFailureBarText = wrapper => wrapper.$el.querySelector('.status-red').innerText.trim(); + const findUnavailableBarText = wrapper => + wrapper.$el.querySelector('.status-unavailable').innerText.trim(); + describe('computed', () => { describe('neutralCount', () => { it('returns neutralCount based on totalCount, successCount and failureCount', () => { @@ -37,24 +44,54 @@ describe('StackedProgressBarComponent', () => { }); }); - describe('methods', () => { + describe('template', () => { + it('renders container element', () => { + expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy(); + }); + + it('renders empty state when count is unavailable', () => { + const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); + + expect(findUnavailableBarText(vmX)).not.toBeUndefined(); + }); + + it('renders bar elements when count is available', () => { + expect(findSuccessBarText(vm)).not.toBeUndefined(); + expect(findNeutralBarText(vm)).not.toBeUndefined(); + expect(findFailureBarText(vm)).not.toBeUndefined(); + }); + describe('getPercent', () => { - it('returns percentage from provided count based on `totalCount`', () => { - expect(vm.getPercent(500)).toBe(10); + it('returns correct percentages from provided count based on `totalCount`', () => { + vm = createComponent({ totalCount: 100, successCount: 25, failureCount: 10 }); + + expect(findSuccessBarText(vm)).toBe('25%'); + expect(findNeutralBarText(vm)).toBe('65%'); + expect(findFailureBarText(vm)).toBe('10%'); }); - it('returns percentage with decimal place from provided count based on `totalCount`', () => { - expect(vm.getPercent(67)).toBe(1.3); + it('returns percentage with decimal place when decimal is greater than 1', () => { + vm = createComponent({ successCount: 67 }); + + expect(findSuccessBarText(vm)).toBe('1.3%'); }); - it('returns percentage as `< 1` from provided count based on `totalCount` when evaluated value is less than 1', () => { - expect(vm.getPercent(10)).toBe('< 1'); + it('returns percentage as `< 1%` from provided count based on `totalCount` when evaluated value is less than 1', () => { + vm = createComponent({ successCount: 10 }); + + expect(findSuccessBarText(vm)).toBe('< 1%'); }); - it('returns 0 if totalCount is falsy', () => { + it('returns not available if totalCount is falsy', () => { vm = createComponent({ totalCount: 0 }); - expect(vm.getPercent(100)).toBe(0); + expect(findUnavailableBarText(vm)).toBe('Not available'); + }); + + it('returns 99.9% when numbers are extreme decimals', () => { + vm = createComponent({ totalCount: 1000000 }); + + expect(findNeutralBarText(vm)).toBe('99.9%'); }); }); @@ -82,23 +119,4 @@ describe('StackedProgressBarComponent', () => { }); }); }); - - describe('template', () => { - it('renders container element', () => { - expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy(); - }); - - it('renders empty state when count is unavailable', () => { - const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); - - expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0); - vmX.$destroy(); - }); - - it('renders bar elements when count is available', () => { - expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0); - expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0); - expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0); - }); - }); }); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap index 1ca5360fa59..d2fe3cd76cb 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap +++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap @@ -1,11 +1,90 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = ` +exports[`Upload dropzone component correctly overrides description and drop messages 1`] = ` <div class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + > + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" + > + <gl-icon-stub + class="gl-mb-2" + name="upload" + size="24" + /> + + <p + class="gl-mb-0" + > + <span> + Test %{linkStart}description%{linkEnd} message. + </span> + </p> + </div> + </button> + + <input + accept="image/jpg,image/jpeg" + class="hide" + multiple="multiple" + name="upload_file" + type="file" + /> + + <transition-stub + name="upload-dropzone-fade" + > + <div + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + style="display: none;" + > + <div + class="mw-50 gl-text-center" + > + <h3 + class="" + > + + Oh no! + + </h3> + + <span> + You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. + </span> + </div> + + <div + class="mw-50 gl-text-center" + style="display: none;" + > + <h3 + class="" + > + + Incoming! + + </h3> + + <span> + Test drop-to-start message. + </span> + </div> + </div> + </transition-stub> +</div> +`; + +exports[`Upload dropzone component when dragging renders correct template when drag event contains files 1`] = ` +<div + class="gl-w-full gl-relative" +> + <button + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -21,7 +100,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="gl-mb-0" > <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} designs to attach" + message="Drop or %{linkStart}upload%{linkEnd} files to attach" /> </p> </div> @@ -31,15 +110,15 @@ exports[`Design management dropzone component when dragging renders correct temp accept="image/*" class="hide" multiple="multiple" - name="design_file" + name="upload_file" type="file" /> <transition-stub - name="design-dropzone-fade" + name="upload-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="" > <div @@ -49,7 +128,9 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Oh no! + + Oh no! + </h3> <span> @@ -64,11 +145,13 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Incoming! + + Incoming! + </h3> <span> - Drop your designs to start your upload. + Drop your files to start your upload. </span> </div> </div> @@ -76,12 +159,12 @@ exports[`Design management dropzone component when dragging renders correct temp </div> `; -exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = ` +exports[`Upload dropzone component when dragging renders correct template when drag event contains files and text 1`] = ` <div class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -97,7 +180,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="gl-mb-0" > <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} designs to attach" + message="Drop or %{linkStart}upload%{linkEnd} files to attach" /> </p> </div> @@ -107,15 +190,15 @@ exports[`Design management dropzone component when dragging renders correct temp accept="image/*" class="hide" multiple="multiple" - name="design_file" + name="upload_file" type="file" /> <transition-stub - name="design-dropzone-fade" + name="upload-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="" > <div @@ -125,7 +208,9 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Oh no! + + Oh no! + </h3> <span> @@ -140,11 +225,13 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Incoming! + + Incoming! + </h3> <span> - Drop your designs to start your upload. + Drop your files to start your upload. </span> </div> </div> @@ -152,12 +239,12 @@ exports[`Design management dropzone component when dragging renders correct temp </div> `; -exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = ` +exports[`Upload dropzone component when dragging renders correct template when drag event contains text 1`] = ` <div class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -173,7 +260,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="gl-mb-0" > <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} designs to attach" + message="Drop or %{linkStart}upload%{linkEnd} files to attach" /> </p> </div> @@ -183,15 +270,15 @@ exports[`Design management dropzone component when dragging renders correct temp accept="image/*" class="hide" multiple="multiple" - name="design_file" + name="upload_file" type="file" /> <transition-stub - name="design-dropzone-fade" + name="upload-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="" > <div @@ -200,7 +287,9 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Oh no! + + Oh no! + </h3> <span> @@ -215,11 +304,13 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Incoming! + + Incoming! + </h3> <span> - Drop your designs to start your upload. + Drop your files to start your upload. </span> </div> </div> @@ -227,12 +318,12 @@ exports[`Design management dropzone component when dragging renders correct temp </div> `; -exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = ` +exports[`Upload dropzone component when dragging renders correct template when drag event is empty 1`] = ` <div class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -248,7 +339,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="gl-mb-0" > <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} designs to attach" + message="Drop or %{linkStart}upload%{linkEnd} files to attach" /> </p> </div> @@ -258,15 +349,15 @@ exports[`Design management dropzone component when dragging renders correct temp accept="image/*" class="hide" multiple="multiple" - name="design_file" + name="upload_file" type="file" /> <transition-stub - name="design-dropzone-fade" + name="upload-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="" > <div @@ -275,7 +366,9 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Oh no! + + Oh no! + </h3> <span> @@ -290,11 +383,13 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Incoming! + + Incoming! + </h3> <span> - Drop your designs to start your upload. + Drop your files to start your upload. </span> </div> </div> @@ -302,12 +397,12 @@ exports[`Design management dropzone component when dragging renders correct temp </div> `; -exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = ` +exports[`Upload dropzone component when dragging renders correct template when dragging stops 1`] = ` <div class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -323,7 +418,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="gl-mb-0" > <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} designs to attach" + message="Drop or %{linkStart}upload%{linkEnd} files to attach" /> </p> </div> @@ -333,15 +428,15 @@ exports[`Design management dropzone component when dragging renders correct temp accept="image/*" class="hide" multiple="multiple" - name="design_file" + name="upload_file" type="file" /> <transition-stub - name="design-dropzone-fade" + name="upload-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="display: none;" > <div @@ -350,7 +445,9 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Oh no! + + Oh no! + </h3> <span> @@ -365,11 +462,13 @@ exports[`Design management dropzone component when dragging renders correct temp <h3 class="" > - Incoming! + + Incoming! + </h3> <span> - Drop your designs to start your upload. + Drop your files to start your upload. </span> </div> </div> @@ -377,12 +476,12 @@ exports[`Design management dropzone component when dragging renders correct temp </div> `; -exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = ` +exports[`Upload dropzone component when no slot provided renders default dropzone card 1`] = ` <div class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -398,7 +497,7 @@ exports[`Design management dropzone component when no slot provided renders defa class="gl-mb-0" > <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} designs to attach" + message="Drop or %{linkStart}upload%{linkEnd} files to attach" /> </p> </div> @@ -408,15 +507,15 @@ exports[`Design management dropzone component when no slot provided renders defa accept="image/*" class="hide" multiple="multiple" - name="design_file" + name="upload_file" type="file" /> <transition-stub - name="design-dropzone-fade" + name="upload-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="display: none;" > <div @@ -425,7 +524,9 @@ exports[`Design management dropzone component when no slot provided renders defa <h3 class="" > - Oh no! + + Oh no! + </h3> <span> @@ -440,11 +541,13 @@ exports[`Design management dropzone component when no slot provided renders defa <h3 class="" > - Incoming! + + Incoming! + </h3> <span> - Drop your designs to start your upload. + Drop your files to start your upload. </span> </div> </div> @@ -452,7 +555,7 @@ exports[`Design management dropzone component when no slot provided renders defa </div> `; -exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = ` +exports[`Upload dropzone component when slot provided renders dropzone with slot content 1`] = ` <div class="gl-w-full gl-relative" > @@ -461,10 +564,10 @@ exports[`Design management dropzone component when slot provided renders dropzon </div> <transition-stub - name="design-dropzone-fade" + name="upload-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="display: none;" > <div @@ -473,7 +576,9 @@ exports[`Design management dropzone component when slot provided renders dropzon <h3 class="" > - Oh no! + + Oh no! + </h3> <span> @@ -488,11 +593,13 @@ exports[`Design management dropzone component when slot provided renders dropzon <h3 class="" > - Incoming! + + Incoming! + </h3> <span> - Drop your designs to start your upload. + Drop your files to start your upload. </span> </div> </div> diff --git a/spec/frontend/design_management/components/upload/design_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js index bf97399368f..11982eb513d 100644 --- a/spec/frontend/design_management/components/upload/design_dropzone_spec.js +++ b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js @@ -1,26 +1,25 @@ import { shallowMount } from '@vue/test-utils'; import { GlIcon } from '@gitlab/ui'; -import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; jest.mock('~/flash'); -describe('Design management dropzone component', () => { +describe('Upload dropzone component', () => { let wrapper; const mockDragEvent = ({ types = ['Files'], files = [] }) => { return { dataTransfer: { types, files } }; }; - const findDropzoneCard = () => wrapper.find('.design-dropzone-card'); + const findDropzoneCard = () => wrapper.find('.upload-dropzone-card'); const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]'); const findIcon = () => wrapper.find(GlIcon); function createComponent({ slots = {}, data = {}, props = {} } = {}) { - wrapper = shallowMount(DesignDropzone, { + wrapper = shallowMount(UploadDropzone, { slots, propsData: { - hasDesigns: true, + displayAsCard: true, ...props, }, data() { @@ -126,28 +125,50 @@ describe('Design management dropzone component', () => { expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); }); - it('calls createFlash when files are invalid', () => { + it('emits error event when files are invalid', () => { createComponent({ data: mockData }); + const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] }); + + wrapper.vm.ondrop(mockEvent); + expect(wrapper.emitted()).toHaveProperty('error'); + }); + + it('allows validation function to be overwritten', () => { + createComponent({ data: mockData, props: { isFileValid: () => true } }); const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] }); wrapper.vm.ondrop(mockEvent); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(wrapper.emitted()).not.toHaveProperty('error'); }); }); }); - it('applies correct classes when there are no designs or no design saving loader', () => { - createComponent({ props: { hasDesigns: false } }); + it('applies correct classes when displaying as a standalone item', () => { + createComponent({ props: { displayAsCard: false } }); expect(findDropzoneArea().classes()).not.toContain('gl-flex-direction-column'); expect(findIcon().classes()).toEqual(['gl-mr-3', 'gl-text-gray-500']); expect(findIcon().props('size')).toBe(16); }); - it('applies correct classes when there are designs or design saving loader', () => { - createComponent({ props: { hasDesigns: true } }); + it('applies correct classes when displaying in card mode', () => { + createComponent({ props: { displayAsCard: true } }); expect(findDropzoneArea().classes()).toContain('gl-flex-direction-column'); expect(findIcon().classes()).toEqual(['gl-mb-2']); expect(findIcon().props('size')).toBe(24); }); + + it('correctly overrides description and drop messages', () => { + createComponent({ + props: { + dropToStartMessage: 'Test drop-to-start message.', + validFileMimetypes: ['image/jpg', 'image/jpeg'], + }, + slots: { + 'upload-text': '<span>Test %{linkStart}description%{linkEnd} message.</span>', + }, + }); + + expect(wrapper.element).toMatchSnapshot(); + }); }); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index c208d7b0226..7d58a865ba3 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -1,6 +1,8 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; +import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue'; +import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; const DEFAULT_PROPS = { user: { @@ -34,6 +36,7 @@ describe('User Popover Component', () => { const findByTestId = testid => wrapper.find(`[data-testid="${testid}"]`); const findUserStatus = () => wrapper.find('.js-user-status'); const findTarget = () => document.querySelector('.js-user-link'); + const findAvailabilityStatus = () => wrapper.find(UserAvailabilityStatus); const createWrapper = (props = {}, options = {}) => { wrapper = shallowMount(UserPopover, { @@ -43,7 +46,8 @@ describe('User Popover Component', () => { ...props, }, stubs: { - 'gl-sprintf': GlSprintf, + GlSprintf, + UserAvailabilityStatus, }, ...options, }); @@ -199,6 +203,30 @@ describe('User Popover Component', () => { expect(findUserStatus().exists()).toBe(false); }); + + it('should show the busy status if user set to busy', () => { + const user = { + ...DEFAULT_PROPS.user, + status: { availability: AVAILABILITY_STATUS.BUSY }, + }; + + createWrapper({ user }); + + expect(findAvailabilityStatus().exists()).toBe(true); + expect(wrapper.text()).toContain(user.name); + expect(wrapper.text()).toContain('(Busy)'); + }); + + it('should hide the busy status for any other status', () => { + const user = { + ...DEFAULT_PROPS.user, + status: { availability: AVAILABILITY_STATUS.NOT_SET }, + }; + + createWrapper({ user }); + + expect(wrapper.text()).not.toContain('(Busy)'); + }); }); describe('security bot', () => { diff --git a/spec/frontend/vue_shared/directives/validation_spec.js b/spec/frontend/vue_shared/directives/validation_spec.js new file mode 100644 index 00000000000..814d6f43589 --- /dev/null +++ b/spec/frontend/vue_shared/directives/validation_spec.js @@ -0,0 +1,132 @@ +import { shallowMount } from '@vue/test-utils'; +import validation from '~/vue_shared/directives/validation'; + +describe('validation directive', () => { + let wrapper; + + const createComponent = ({ inputAttributes, showValidation } = {}) => { + const defaultInputAttributes = { + type: 'text', + required: true, + }; + + const component = { + directives: { + validation: validation(), + }, + data() { + return { + attributes: inputAttributes || defaultInputAttributes, + showValidation, + form: { + state: null, + fields: { + exampleField: { + state: null, + feedback: '', + }, + }, + }, + }; + }, + template: ` + <form> + <input v-validation:[showValidation] name="exampleField" v-bind="attributes" /> + </form> + `, + }; + + wrapper = shallowMount(component, { attachToDocument: true }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const getFormData = () => wrapper.vm.form; + const findForm = () => wrapper.find('form'); + const findInput = () => wrapper.find('input'); + + describe.each([true, false])( + 'with fields untouched and "showValidation" set to "%s"', + showValidation => { + beforeEach(() => { + createComponent({ showValidation }); + }); + + it('sets the fields validity correctly', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: showValidation ? false : null, + feedback: showValidation ? expect.any(String) : '', + }); + }); + + it('sets the form validity correctly', () => { + expect(getFormData().state).toBe(false); + }); + }, + ); + + describe.each` + inputAttributes | validValue | invalidValue + ${{ required: true }} | ${'foo'} | ${''} + ${{ type: 'url' }} | ${'http://foo.com'} | ${'foo'} + ${{ type: 'number', min: 1, max: 5 }} | ${3} | ${0} + ${{ type: 'number', min: 1, max: 5 }} | ${3} | ${6} + ${{ pattern: 'foo|bar' }} | ${'bar'} | ${'quz'} + `( + 'with input-attributes set to $inputAttributes', + ({ inputAttributes, validValue, invalidValue }) => { + const setValueAndTriggerValidation = value => { + const input = findInput(); + input.setValue(value); + input.trigger('blur'); + }; + + beforeEach(() => { + createComponent({ inputAttributes }); + }); + + describe('with valid value', () => { + beforeEach(() => { + setValueAndTriggerValidation(validValue); + }); + + it('sets the field to be valid', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: true, + feedback: '', + }); + }); + + it('sets the form to be valid', () => { + expect(getFormData().state).toBe(true); + }); + }); + + describe('with invalid value', () => { + beforeEach(() => { + setValueAndTriggerValidation(invalidValue); + }); + + it('sets the field to be invalid', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: false, + feedback: expect.any(String), + }); + expect(getFormData().fields.exampleField.feedback.length).toBeGreaterThan(0); + }); + + it('sets the form to be invalid', () => { + expect(getFormData().state).toBe(false); + }); + + it('sets focus on the first invalid input when the form is submitted', () => { + findForm().trigger('submit'); + expect(findInput().element).toBe(document.activeElement); + }); + }); + }, + ); +}); diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js index 31bdfc931ac..ab87d80b291 100644 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -5,7 +5,7 @@ import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_a jest.mock('~/flash'); -describe('Grouped security reports app', () => { +describe('Security reports app', () => { let wrapper; let mrTabsMock; @@ -21,6 +21,8 @@ describe('Grouped security reports app', () => { }); }; + const anyParams = expect.any(Object); + const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]'); const findHelpLink = () => wrapper.find('[data-testid="help"]'); const setupMrTabsMock = () => { @@ -43,10 +45,12 @@ describe('Grouped security reports app', () => { window.mrTabs = { tabShown: jest.fn() }; setupMockJobArtifact(reportType); createComponent(); + return wrapper.vm.$nextTick(); }); it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId); + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); }); it('renders the expected message', () => { @@ -75,10 +79,12 @@ describe('Grouped security reports app', () => { beforeEach(() => { setupMockJobArtifact('foo'); createComponent(); + return wrapper.vm.$nextTick(); }); it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId); + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); }); it('renders nothing', () => { @@ -86,6 +92,42 @@ describe('Grouped security reports app', () => { }); }); + describe('security artifacts on last page of multi-page response', () => { + const numPages = 3; + + beforeEach(() => { + jest + .spyOn(Api, 'pipelineJobs') + .mockImplementation(async (projectId, pipelineId, { page }) => { + const requestedPage = parseInt(page, 10); + if (requestedPage < numPages) { + return { + // Some jobs with no relevant artifacts + data: [{}, {}], + headers: { 'x-next-page': String(requestedPage + 1) }, + }; + } else if (requestedPage === numPages) { + return { + data: [{ artifacts: [{ file_type: SecurityReportsApp.reportTypes[0] }] }], + }; + } + + throw new Error('Test failed due to request of non-existent jobs page'); + }); + + createComponent(); + return wrapper.vm.$nextTick(); + }); + + it('fetches all pages', () => { + expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages); + }); + + it('renders the expected message', () => { + expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun); + }); + }); + describe('given an error from the API', () => { let error; @@ -93,10 +135,12 @@ describe('Grouped security reports app', () => { error = new Error('an error'); jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error); createComponent(); + return wrapper.vm.$nextTick(); }); it('calls the pipelineJobs API correctly', () => { - expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId); + expect(Api.pipelineJobs).toHaveBeenCalledTimes(1); + expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams); }); it('renders nothing', () => { diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js new file mode 100644 index 00000000000..a11f4e05913 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js @@ -0,0 +1,203 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; + +import createState from '~/vue_shared/security_reports/store/modules/sast/state'; +import * as types from '~/vue_shared/security_reports/store/modules/sast/mutation_types'; +import * as actions from '~/vue_shared/security_reports/store/modules/sast/actions'; +import axios from '~/lib/utils/axios_utils'; + +const diffEndpoint = 'diff-endpoint.json'; +const blobPath = 'blob-path.json'; +const reports = { + base: 'base', + head: 'head', + enrichData: 'enrichData', + diff: 'diff', +}; +const error = 'Something went wrong'; +const vulnerabilityFeedbackPath = 'vulnerability-feedback-path'; +const rootState = { vulnerabilityFeedbackPath, blobPath }; + +let state; + +describe('sast report actions', () => { + beforeEach(() => { + state = createState(); + }); + + describe('setDiffEndpoint', () => { + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, done => { + testAction( + actions.setDiffEndpoint, + diffEndpoint, + state, + [ + { + type: types.SET_DIFF_ENDPOINT, + payload: diffEndpoint, + }, + ], + [], + done, + ); + }); + }); + + describe('requestDiff', () => { + it(`should commit ${types.REQUEST_DIFF}`, done => { + testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + }); + }); + + describe('receiveDiffSuccess', () => { + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, done => { + testAction( + actions.receiveDiffSuccess, + reports, + state, + [ + { + type: types.RECEIVE_DIFF_SUCCESS, + payload: reports, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveDiffError', () => { + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, done => { + testAction( + actions.receiveDiffError, + error, + state, + [ + { + type: types.RECEIVE_DIFF_ERROR, + payload: error, + }, + ], + [], + done, + ); + }); + }); + + describe('fetchDiff', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state.paths.diffEndpoint = diffEndpoint; + rootState.canReadVulnerabilityFeedback = true; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('when diff and vulnerability feedback endpoints respond successfully', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(200, reports.diff) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(200, reports.enrichData); + }); + + it('should dispatch the `receiveDiffSuccess` action', done => { + const { diff, enrichData } = reports; + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [ + { type: 'requestDiff' }, + { + type: 'receiveDiffSuccess', + payload: { + diff, + enrichData, + }, + }, + ], + done, + ); + }); + }); + + describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => { + beforeEach(() => { + rootState.canReadVulnerabilityFeedback = false; + mock.onGet(diffEndpoint).replyOnce(200, reports.diff); + }); + + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', done => { + const { diff } = reports; + const enrichData = []; + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [ + { type: 'requestDiff' }, + { + type: 'receiveDiffSuccess', + payload: { + diff, + enrichData, + }, + }, + ], + done, + ); + }); + }); + + describe('when the vulnerability feedback endpoint fails', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(200, reports.diff) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(404); + }); + + it('should dispatch the `receiveError` action', done => { + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], + done, + ); + }); + }); + + describe('when the diff endpoint fails', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(404) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(200, reports.enrichData); + }); + + it('should dispatch the `receiveDiffError` action', done => { + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], + done, + ); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js new file mode 100644 index 00000000000..fd611f38a34 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js @@ -0,0 +1,84 @@ +import * as types from '~/vue_shared/security_reports/store/modules/sast/mutation_types'; +import createState from '~/vue_shared/security_reports/store/modules/sast/state'; +import mutations from '~/vue_shared/security_reports/store/modules/sast/mutations'; + +const createIssue = ({ ...config }) => ({ changed: false, ...config }); + +describe('sast module mutations', () => { + const path = 'path'; + let state; + + beforeEach(() => { + state = createState(); + }); + + describe(types.SET_DIFF_ENDPOINT, () => { + it('should set the SAST diff endpoint', () => { + mutations[types.SET_DIFF_ENDPOINT](state, path); + + expect(state.paths.diffEndpoint).toBe(path); + }); + }); + + describe(types.REQUEST_DIFF, () => { + it('should set the `isLoading` status to `true`', () => { + mutations[types.REQUEST_DIFF](state); + + expect(state.isLoading).toBe(true); + }); + }); + + describe(types.RECEIVE_DIFF_SUCCESS, () => { + beforeEach(() => { + const reports = { + diff: { + added: [ + createIssue({ cve: 'CVE-1' }), + createIssue({ cve: 'CVE-2' }), + createIssue({ cve: 'CVE-3' }), + ], + fixed: [createIssue({ cve: 'CVE-4' }), createIssue({ cve: 'CVE-5' })], + existing: [createIssue({ cve: 'CVE-6' })], + base_report_out_of_date: true, + }, + }; + state.isLoading = true; + mutations[types.RECEIVE_DIFF_SUCCESS](state, reports); + }); + + it('should set the `isLoading` status to `false`', () => { + expect(state.isLoading).toBe(false); + }); + + it('should set the `baseReportOutofDate` status to `false`', () => { + expect(state.baseReportOutofDate).toBe(true); + }); + + it('should have the relevant `new` issues', () => { + expect(state.newIssues).toHaveLength(3); + }); + + it('should have the relevant `resolved` issues', () => { + expect(state.resolvedIssues).toHaveLength(2); + }); + + it('should have the relevant `all` issues', () => { + expect(state.allIssues).toHaveLength(1); + }); + }); + + describe(types.RECEIVE_DIFF_ERROR, () => { + beforeEach(() => { + state.isLoading = true; + mutations[types.RECEIVE_DIFF_ERROR](state); + }); + + it('should set the `isLoading` status to `false`', () => { + expect(state.isLoading).toBe(false); + }); + + it('should set the `hasError` status to `true`', () => { + expect(state.hasError).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js new file mode 100644 index 00000000000..bbcdfb5cd99 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js @@ -0,0 +1,203 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; + +import createState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; +import * as types from '~/vue_shared/security_reports/store/modules/secret_detection/mutation_types'; +import * as actions from '~/vue_shared/security_reports/store/modules/secret_detection/actions'; +import axios from '~/lib/utils/axios_utils'; + +const diffEndpoint = 'diff-endpoint.json'; +const blobPath = 'blob-path.json'; +const reports = { + base: 'base', + head: 'head', + enrichData: 'enrichData', + diff: 'diff', +}; +const error = 'Something went wrong'; +const vulnerabilityFeedbackPath = 'vulnerability-feedback-path'; +const rootState = { vulnerabilityFeedbackPath, blobPath }; + +let state; + +describe('secret detection report actions', () => { + beforeEach(() => { + state = createState(); + }); + + describe('setDiffEndpoint', () => { + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, done => { + testAction( + actions.setDiffEndpoint, + diffEndpoint, + state, + [ + { + type: types.SET_DIFF_ENDPOINT, + payload: diffEndpoint, + }, + ], + [], + done, + ); + }); + }); + + describe('requestDiff', () => { + it(`should commit ${types.REQUEST_DIFF}`, done => { + testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + }); + }); + + describe('receiveDiffSuccess', () => { + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, done => { + testAction( + actions.receiveDiffSuccess, + reports, + state, + [ + { + type: types.RECEIVE_DIFF_SUCCESS, + payload: reports, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveDiffError', () => { + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, done => { + testAction( + actions.receiveDiffError, + error, + state, + [ + { + type: types.RECEIVE_DIFF_ERROR, + payload: error, + }, + ], + [], + done, + ); + }); + }); + + describe('fetchDiff', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state.paths.diffEndpoint = diffEndpoint; + rootState.canReadVulnerabilityFeedback = true; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('when diff and vulnerability feedback endpoints respond successfully', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(200, reports.diff) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(200, reports.enrichData); + }); + + it('should dispatch the `receiveDiffSuccess` action', done => { + const { diff, enrichData } = reports; + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [ + { type: 'requestDiff' }, + { + type: 'receiveDiffSuccess', + payload: { + diff, + enrichData, + }, + }, + ], + done, + ); + }); + }); + + describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => { + beforeEach(() => { + rootState.canReadVulnerabilityFeedback = false; + mock.onGet(diffEndpoint).replyOnce(200, reports.diff); + }); + + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', done => { + const { diff } = reports; + const enrichData = []; + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [ + { type: 'requestDiff' }, + { + type: 'receiveDiffSuccess', + payload: { + diff, + enrichData, + }, + }, + ], + done, + ); + }); + }); + + describe('when the vulnerability feedback endpoint fails', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(200, reports.diff) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(404); + }); + + it('should dispatch the `receiveDiffError` action', done => { + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], + done, + ); + }); + }); + + describe('when the diff endpoint fails', () => { + beforeEach(() => { + mock + .onGet(diffEndpoint) + .replyOnce(404) + .onGet(vulnerabilityFeedbackPath) + .replyOnce(200, reports.enrichData); + }); + + it('should dispatch the `receiveDiffError` action', done => { + testAction( + actions.fetchDiff, + {}, + { ...rootState, ...state }, + [], + [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], + done, + ); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js new file mode 100644 index 00000000000..13fcc0f47a3 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js @@ -0,0 +1,84 @@ +import * as types from '~/vue_shared/security_reports/store/modules/secret_detection/mutation_types'; +import createState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; +import mutations from '~/vue_shared/security_reports/store/modules/secret_detection/mutations'; + +const createIssue = ({ ...config }) => ({ changed: false, ...config }); + +describe('secret detection module mutations', () => { + const path = 'path'; + let state; + + beforeEach(() => { + state = createState(); + }); + + describe(types.SET_DIFF_ENDPOINT, () => { + it('should set the secret detection diff endpoint', () => { + mutations[types.SET_DIFF_ENDPOINT](state, path); + + expect(state.paths.diffEndpoint).toBe(path); + }); + }); + + describe(types.REQUEST_DIFF, () => { + it('should set the `isLoading` status to `true`', () => { + mutations[types.REQUEST_DIFF](state); + + expect(state.isLoading).toBe(true); + }); + }); + + describe(types.RECEIVE_DIFF_SUCCESS, () => { + beforeEach(() => { + const reports = { + diff: { + added: [ + createIssue({ cve: 'CVE-1' }), + createIssue({ cve: 'CVE-2' }), + createIssue({ cve: 'CVE-3' }), + ], + fixed: [createIssue({ cve: 'CVE-4' }), createIssue({ cve: 'CVE-5' })], + existing: [createIssue({ cve: 'CVE-6' })], + base_report_out_of_date: true, + }, + }; + state.isLoading = true; + mutations[types.RECEIVE_DIFF_SUCCESS](state, reports); + }); + + it('should set the `isLoading` status to `false`', () => { + expect(state.isLoading).toBe(false); + }); + + it('should set the `baseReportOutofDate` status to `true`', () => { + expect(state.baseReportOutofDate).toBe(true); + }); + + it('should have the relevant `new` issues', () => { + expect(state.newIssues).toHaveLength(3); + }); + + it('should have the relevant `resolved` issues', () => { + expect(state.resolvedIssues).toHaveLength(2); + }); + + it('should have the relevant `all` issues', () => { + expect(state.allIssues).toHaveLength(1); + }); + }); + + describe(types.RECEIVE_DIFF_ERROR, () => { + beforeEach(() => { + state.isLoading = true; + mutations[types.RECEIVE_DIFF_ERROR](state); + }); + + it('should set the `isLoading` status to `false`', () => { + expect(state.isLoading).toBe(false); + }); + + it('should set the `hasError` status to `true`', () => { + expect(state.hasError).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vuex_shared/modules/members/actions_spec.js b/spec/frontend/vuex_shared/modules/members/actions_spec.js index 833bd4cc175..c7048a9c421 100644 --- a/spec/frontend/vuex_shared/modules/members/actions_spec.js +++ b/spec/frontend/vuex_shared/modules/members/actions_spec.js @@ -3,79 +3,121 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { members, group } from 'jest/vue_shared/components/members/mock_data'; import testAction from 'helpers/vuex_action_helper'; +import { useFakeDate } from 'helpers/fake_date'; import httpStatusCodes from '~/lib/utils/http_status'; import * as types from '~/vuex_shared/modules/members/mutation_types'; import { updateMemberRole, showRemoveGroupLinkModal, hideRemoveGroupLinkModal, + updateMemberExpiration, } from '~/vuex_shared/modules/members/actions'; describe('Vuex members actions', () => { - let mock; + describe('update member actions', () => { + let mock; - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('updateMemberRole', () => { - const memberId = members[0].id; - const accessLevel = { integerValue: 30, stringValue: 'Developer' }; - - const payload = { - memberId, - accessLevel, - }; const state = { members, memberPath: '/groups/foo-bar/-/group_members/:id', requestFormatter: noop, - removeGroupLinkModalVisible: false, - groupLinkToRemove: null, }; - describe('successful request', () => { - it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => { - let requestPath; - mock.onPut().replyOnce(config => { - requestPath = config.url; - return [httpStatusCodes.OK, {}]; - }); - - await testAction(updateMemberRole, payload, state, [ - { - type: types.RECEIVE_MEMBER_ROLE_SUCCESS, - payload, - }, - ]); + beforeEach(() => { + mock = new MockAdapter(axios); + }); - expect(requestPath).toBe('/groups/foo-bar/-/group_members/238'); - }); + afterEach(() => { + mock.restore(); }); - describe('unsuccessful request', () => { - beforeEach(() => { - mock.onPut().replyOnce(httpStatusCodes.BAD_REQUEST, { message: 'Bad request' }); - }); + describe('updateMemberRole', () => { + const memberId = members[0].id; + const accessLevel = { integerValue: 30, stringValue: 'Developer' }; + + const payload = { + memberId, + accessLevel, + }; + + describe('successful request', () => { + it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => { + mock.onPut().replyOnce(httpStatusCodes.OK); - it(`commits ${types.RECEIVE_MEMBER_ROLE_ERROR} mutation`, async () => { - try { await testAction(updateMemberRole, payload, state, [ { type: types.RECEIVE_MEMBER_ROLE_SUCCESS, + payload, }, ]); - } catch { - // Do nothing - } + + expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238'); + }); + }); + + describe('unsuccessful request', () => { + it(`commits ${types.RECEIVE_MEMBER_ROLE_ERROR} mutation and throws error`, async () => { + mock.onPut().networkError(); + + await expect( + testAction(updateMemberRole, payload, state, [ + { + type: types.RECEIVE_MEMBER_ROLE_ERROR, + }, + ]), + ).rejects.toThrowError(new Error('Network Error')); + }); + }); + }); + + describe('updateMemberExpiration', () => { + useFakeDate(2020, 2, 15, 3); + + const memberId = members[0].id; + const expiresAt = '2020-3-17'; + + describe('successful request', () => { + describe('changing expiration date', () => { + it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => { + mock.onPut().replyOnce(httpStatusCodes.OK); + + await testAction(updateMemberExpiration, { memberId, expiresAt }, state, [ + { + type: types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, + payload: { memberId, expiresAt: '2020-03-17T00:00:00Z' }, + }, + ]); + + expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238'); + }); + }); + + describe('removing the expiration date', () => { + it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => { + mock.onPut().replyOnce(httpStatusCodes.OK); + + await testAction(updateMemberExpiration, { memberId, expiresAt: null }, state, [ + { + type: types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, + payload: { memberId, expiresAt: null }, + }, + ]); + }); + }); }); - it('throws error', async () => { - await expect(testAction(updateMemberRole, payload, state)).rejects.toThrowError(); + describe('unsuccessful request', () => { + it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_ERROR} mutation and throws error`, async () => { + mock.onPut().networkError(); + + await expect( + testAction(updateMemberExpiration, { memberId, expiresAt }, state, [ + { + type: types.RECEIVE_MEMBER_EXPIRATION_ERROR, + }, + ]), + ).rejects.toThrowError(new Error('Network Error')); + }); }); }); }); diff --git a/spec/frontend/vuex_shared/modules/members/mutations_spec.js b/spec/frontend/vuex_shared/modules/members/mutations_spec.js index 7338b19cfc9..710d43b8990 100644 --- a/spec/frontend/vuex_shared/modules/members/mutations_spec.js +++ b/spec/frontend/vuex_shared/modules/members/mutations_spec.js @@ -3,36 +3,63 @@ import mutations from '~/vuex_shared/modules/members/mutations'; import * as types from '~/vuex_shared/modules/members/mutation_types'; describe('Vuex members mutations', () => { - describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => { - it('updates member', () => { - const state = { + describe('update member mutations', () => { + let state; + + beforeEach(() => { + state = { members, + showError: false, + errorMessage: '', }; + }); - const accessLevel = { integerValue: 30, stringValue: 'Developer' }; + describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => { + it('updates member', () => { + const accessLevel = { integerValue: 30, stringValue: 'Developer' }; - mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, { - memberId: members[0].id, - accessLevel, + mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, { + memberId: members[0].id, + accessLevel, + }); + + expect(state.members[0].accessLevel).toEqual(accessLevel); }); + }); + + describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => { + it('shows error message', () => { + mutations[types.RECEIVE_MEMBER_ROLE_ERROR](state); - expect(state.members[0].accessLevel).toEqual(accessLevel); + expect(state.showError).toBe(true); + expect(state.errorMessage).toBe( + "An error occurred while updating the member's role, please try again.", + ); + }); }); - }); - describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => { - it('shows error message', () => { - const state = { - showError: false, - errorMessage: '', - }; + describe(types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, () => { + it('updates member', () => { + const expiresAt = '2020-03-17T00:00:00Z'; - mutations[types.RECEIVE_MEMBER_ROLE_ERROR](state); + mutations[types.RECEIVE_MEMBER_EXPIRATION_SUCCESS](state, { + memberId: members[0].id, + expiresAt, + }); - expect(state.showError).toBe(true); - expect(state.errorMessage).toBe( - "An error occurred while updating the member's role, please try again.", - ); + expect(state.members[0].expiresAt).toEqual(expiresAt); + }); + }); + + describe(types.RECEIVE_MEMBER_EXPIRATION_ERROR, () => { + it('shows error message', () => { + mutations[types.RECEIVE_MEMBER_EXPIRATION_ERROR](state); + + expect(state.showError).toBe(true); + expect(state.errorMessage).toBe( + "An error occurred while updating the member's expiration date, please try again.", + ); + }); }); }); diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js index 77c2ae19d1f..cba550b19db 100644 --- a/spec/frontend/whats_new/components/app_spec.js +++ b/spec/frontend/whats_new/components/app_spec.js @@ -1,8 +1,16 @@ import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; -import { GlDrawer } from '@gitlab/ui'; +import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import App from '~/whats_new/components/app.vue'; +import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height'; + +const MOCK_DRAWER_BODY_HEIGHT = 42; + +jest.mock('~/whats_new/utils/get_drawer_body_height', () => ({ + getDrawerBodyHeight: jest.fn().mockImplementation(() => MOCK_DRAWER_BODY_HEIGHT), +})); const localVue = createLocalVue(); localVue.use(Vuex); @@ -20,11 +28,13 @@ describe('App', () => { openDrawer: jest.fn(), closeDrawer: jest.fn(), fetchItems: jest.fn(), + setDrawerBodyHeight: jest.fn(), }; state = { open: true, - features: null, + features: [], + drawerBodyHeight: null, }; store = new Vuex.Store({ @@ -36,9 +46,15 @@ describe('App', () => { localVue, store, propsData, + directives: { + GlResizeObserver: createMockDirective(), + }, }); }; + const findInfiniteScroll = () => wrapper.find(GlInfiniteScroll); + const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached'); + beforeEach(async () => { document.body.dataset.page = 'test-page'; document.body.dataset.namespaceId = 'namespace-840'; @@ -47,6 +63,7 @@ describe('App', () => { buildWrapper(); wrapper.vm.$store.state.features = [{ title: 'Whats New Drawer', url: 'www.url.com' }]; + wrapper.vm.$store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT; await wrapper.vm.$nextTick(); }); @@ -61,7 +78,7 @@ describe('App', () => { expect(getDrawer().exists()).toBe(true); }); - it('dispatches openDrawer when mounted', () => { + it('dispatches openDrawer and tracking calls when mounted', () => { expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key'); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', { label: 'namespace_id', @@ -90,7 +107,7 @@ describe('App', () => { it('send an event when feature item is clicked', () => { trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - const link = wrapper.find('[data-testid="whats-new-title-link"]'); + const link = wrapper.find('.whats-new-item-title-link'); triggerEvent(link.element); expect(trackingSpy.mock.calls[1]).toMatchObject([ @@ -102,4 +119,46 @@ describe('App', () => { }, ]); }); + + it('renders infinite scroll', () => { + const scroll = findInfiniteScroll(); + + expect(scroll.props()).toMatchObject({ + fetchedItems: wrapper.vm.$store.state.features.length, + maxListHeight: MOCK_DRAWER_BODY_HEIGHT, + }); + }); + + describe('bottomReached', () => { + beforeEach(() => { + actions.fetchItems.mockClear(); + }); + + it('when nextPage exists it calls fetchItems', () => { + wrapper.vm.$store.state.pageInfo = { nextPage: 840 }; + emitBottomReached(); + + expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), 840); + }); + + it('when nextPage does not exist it does not call fetchItems', () => { + wrapper.vm.$store.state.pageInfo = { nextPage: null }; + emitBottomReached(); + + expect(actions.fetchItems).not.toHaveBeenCalled(); + }); + }); + + it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => { + const { value } = getBinding(getDrawer().element, 'gl-resize-observer'); + + value(); + + expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.find(GlDrawer).element); + + expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith( + expect.any(Object), + MOCK_DRAWER_BODY_HEIGHT, + ); + }); }); diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js index 95ab667d611..12722b1b3b1 100644 --- a/spec/frontend/whats_new/store/actions_spec.js +++ b/spec/frontend/whats_new/store/actions_spec.js @@ -30,7 +30,9 @@ describe('whats new actions', () => { axiosMock = new MockAdapter(axios); axiosMock .onGet('/-/whats_new') - .replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }]); + .replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }], { + 'x-next-page': '2', + }); await waitForPromises(); }); @@ -39,10 +41,23 @@ describe('whats new actions', () => { axiosMock.restore(); }); - it('should commit setFeatures', () => { + it('if already fetching, does not fetch', () => { + testAction(actions.fetchItems, {}, { fetching: true }, []); + }); + + it('should commit fetching, setFeatures and setPagination', () => { testAction(actions.fetchItems, {}, {}, [ - { type: types.SET_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] }, + { type: types.SET_FETCHING, payload: true }, + { type: types.ADD_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] }, + { type: types.SET_PAGE_INFO, payload: { nextPage: 2 } }, + { type: types.SET_FETCHING, payload: false }, ]); }); }); + + describe('setDrawerBodyHeight', () => { + testAction(actions.setDrawerBodyHeight, 42, {}, [ + { type: types.SET_DRAWER_BODY_HEIGHT, payload: 42 }, + ]); + }); }); diff --git a/spec/frontend/whats_new/store/mutations_spec.js b/spec/frontend/whats_new/store/mutations_spec.js index feaa1dd2a3b..4967fb51d2b 100644 --- a/spec/frontend/whats_new/store/mutations_spec.js +++ b/spec/frontend/whats_new/store/mutations_spec.js @@ -23,10 +23,37 @@ describe('whats new mutations', () => { }); }); - describe('setFeatures', () => { - it('sets features to data', () => { - mutations[types.SET_FEATURES](state, 'bells and whistles'); - expect(state.features).toBe('bells and whistles'); + describe('addFeatures', () => { + it('adds features from data', () => { + mutations[types.ADD_FEATURES](state, ['bells and whistles']); + expect(state.features).toEqual(['bells and whistles']); + }); + + it('when there are already items, it adds items', () => { + state.features = ['shiny things']; + mutations[types.ADD_FEATURES](state, ['bells and whistles']); + expect(state.features).toEqual(['shiny things', 'bells and whistles']); + }); + }); + + describe('setPageInfo', () => { + it('sets page info', () => { + mutations[types.SET_PAGE_INFO](state, { nextPage: 8 }); + expect(state.pageInfo).toEqual({ nextPage: 8 }); + }); + }); + + describe('setFetching', () => { + it('sets fetching', () => { + mutations[types.SET_FETCHING](state, true); + expect(state.fetching).toBe(true); + }); + }); + + describe('setDrawerBodyHeight', () => { + it('sets drawerBodyHeight', () => { + mutations[types.SET_DRAWER_BODY_HEIGHT](state, 840); + expect(state.drawerBodyHeight).toBe(840); }); }); }); diff --git a/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js b/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js new file mode 100644 index 00000000000..d096a3cbdc6 --- /dev/null +++ b/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils'; +import { GlDrawer } from '@gitlab/ui'; +import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height'; + +describe('~/whats_new/utils/get_drawer_body_height', () => { + let drawerWrapper; + + beforeEach(() => { + drawerWrapper = mount(GlDrawer, { + propsData: { open: true }, + }); + }); + + afterEach(() => { + drawerWrapper.destroy(); + }); + + const setClientHeight = (el, height) => { + Object.defineProperty(el, 'clientHeight', { + get() { + return height; + }, + }); + }; + const setDrawerDimensions = ({ height, top, headerHeight }) => { + const drawer = drawerWrapper.element; + + setClientHeight(drawer, height); + jest.spyOn(drawer, 'getBoundingClientRect').mockReturnValue({ top }); + setClientHeight(drawer.querySelector('.gl-drawer-header'), headerHeight); + }; + + it('calculates height of drawer body', () => { + setDrawerDimensions({ height: 100, top: 5, headerHeight: 40 }); + + expect(getDrawerBodyHeight(drawerWrapper.element)).toBe(55); + }); +}); |