Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/vue_shared')
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_details_spec.js10
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_status_spec.js19
-rw-r--r--spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js16
-rw-r--r--spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js31
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/alert_details_table_spec.js83
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap141
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/delete_label_modal_spec.js64
-rw-r--r--spec/frontend/vue_shared/components/deprecated_modal_spec.js73
-rw-r--r--spec/frontend/vue_shared/components/ensure_data_spec.js145
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js47
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js217
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js180
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js16
-rw-r--r--spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js114
-rw-r--r--spec/frontend/vue_shared/components/help_popover_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_spec.js64
-rw-r--r--spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js21
-rw-r--r--spec/frontend/vue_shared/components/recaptcha_modal_spec.js35
-rw-r--r--spec/frontend/vue_shared/components/registry/registry_search_spec.js57
-rw-r--r--spec/frontend/vue_shared/components/remove_member_modal_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/mock_data.js16
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js184
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js110
-rw-r--r--spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js74
-rw-r--r--spec/frontend/vue_shared/components/url_sync_spec.js97
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js6
-rw-r--r--spec/frontend/vue_shared/oncall_schedules_list_spec.js87
31 files changed, 1635 insertions, 515 deletions
diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js
index 68bcf1dc491..1fc655f1ebc 100644
--- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js
+++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js
@@ -8,7 +8,7 @@ import { joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import AlertDetails from '~/vue_shared/alert_details/components/alert_details.vue';
import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary_row.vue';
-import { SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants';
+import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants';
import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import mockAlerts from './mocks/alerts.json';
@@ -271,7 +271,13 @@ describe('AlertDetails', () => {
});
it('should display a table of raw alert details data', () => {
- expect(findDetailsTable().exists()).toBe(true);
+ const details = findDetailsTable();
+ expect(details.exists()).toBe(true);
+ expect(details.props()).toStrictEqual({
+ alert: mockAlert,
+ statuses: PAGE_CONFIG.OPERATIONS.STATUSES,
+ loading: false,
+ });
});
});
diff --git a/spec/frontend/vue_shared/alert_details/alert_status_spec.js b/spec/frontend/vue_shared/alert_details/alert_status_spec.js
index a866fc13539..c532f688cbd 100644
--- a/spec/frontend/vue_shared/alert_details/alert_status_spec.js
+++ b/spec/frontend/vue_shared/alert_details/alert_status_spec.js
@@ -12,6 +12,7 @@ describe('AlertManagementStatus', () => {
let wrapper;
const findStatusDropdown = () => wrapper.find(GlDropdown);
const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
+ const findAllStatusOptions = () => findStatusDropdown().findAll(GlDropdownItem);
const selectFirstStatusOption = () => {
findFirstStatusOption().vm.$emit('click');
@@ -131,6 +132,24 @@ describe('AlertManagementStatus', () => {
});
});
+ describe('Statuses', () => {
+ it('renders default translated statuses', () => {
+ mountComponent({});
+ expect(findAllStatusOptions().length).toBe(3);
+ expect(findFirstStatusOption().text()).toBe('Triggered');
+ });
+
+ it('renders translated statuses', () => {
+ const status = 'TEST';
+ const translatedStatus = 'Test';
+ mountComponent({
+ props: { alert: { ...mockAlert, status }, statuses: { [status]: translatedStatus } },
+ });
+ expect(findAllStatusOptions().length).toBe(1);
+ expect(findFirstStatusOption().text()).toBe(translatedStatus);
+ });
+ });
+
describe('Snowplow tracking', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js
index 70cf2597963..ef75e038bff 100644
--- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js
+++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_spec.js
@@ -76,20 +76,4 @@ describe('Alert Details Sidebar', () => {
expect(wrapper.find(SidebarStatus).exists()).toBe(true);
});
});
-
- describe('the sidebar renders for threat monitoring', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mountComponent();
- });
-
- it('should not render side bar status dropdown', () => {
- mountComponent({
- mountMethod: mount,
- alert: mockAlert,
- provide: { isThreatMonitoringPage: true },
- });
- expect(wrapper.find(SidebarStatus).exists()).toBe(false);
- });
- });
});
diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js
index f5b9efb4d98..0014957517f 100644
--- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js
+++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js
@@ -1,7 +1,9 @@
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql';
+import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue';
import AlertSidebarStatus from '~/vue_shared/alert_details/components/sidebar/sidebar_status.vue';
+import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants';
import mockAlerts from '../mocks/alerts.json';
const mockAlert = mockAlerts[0];
@@ -12,8 +14,16 @@ describe('Alert Details Sidebar Status', () => {
const findStatusDropdownItem = () => wrapper.find(GlDropdownItem);
const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findStatusDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]');
+ const findAlertStatus = () => wrapper.findComponent(AlertStatus);
+ const findStatus = () => wrapper.find('[data-testid="status"]');
- function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) {
+ function mountComponent({
+ data,
+ sidebarCollapsed = true,
+ loading = false,
+ stubs = {},
+ provide = {},
+ } = {}) {
wrapper = mount(AlertSidebarStatus, {
propsData: {
alert: { ...mockAlert },
@@ -32,6 +42,7 @@ describe('Alert Details Sidebar Status', () => {
},
},
stubs,
+ provide,
});
}
@@ -96,8 +107,24 @@ describe('Alert Details Sidebar Status', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
findStatusDropdownItem().vm.$emit('click');
expect(findStatusLoadingIcon().exists()).toBe(false);
- expect(wrapper.find('[data-testid="status"]').text()).toBe('Triggered');
+ expect(findStatus().text()).toBe('Triggered');
});
});
});
+
+ describe('Statuses', () => {
+ it('renders default translated statuses', () => {
+ mountComponent({});
+ expect(findAlertStatus().props('statuses')).toBe(PAGE_CONFIG.OPERATIONS.STATUSES);
+ expect(findStatus().text()).toBe('Triggered');
+ });
+
+ it('renders translated statuses', () => {
+ const status = 'TEST';
+ const statuses = { [status]: 'Test' };
+ mountComponent({ data: { alert: { ...mockAlert, status } }, provide: { statuses } });
+ expect(findAlertStatus().props('statuses')).toBe(statuses);
+ expect(findStatus().text()).toBe(statuses.TEST);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
index 1bf757ea312..bab928318ce 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
@@ -40,6 +40,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
+ aria-label="Copy URL"
buttontextclasses=""
category="primary"
class="d-inline-flex"
@@ -82,6 +83,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
+ aria-label="Copy URL"
buttontextclasses=""
category="primary"
class="d-inline-flex"
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 49b82cb4d4e..03b04a92bdf 100644
--- a/spec/frontend/vue_shared/components/alert_details_table_spec.js
+++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js
@@ -75,45 +75,62 @@ describe('AlertDetails', () => {
});
describe('with table data', () => {
- beforeEach(mountComponent);
-
- it('renders a table', () => {
- expect(findTableComponent().exists()).toBe(true);
- });
-
- it('renders a cell based on alert data', () => {
- expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token');
- });
-
- it('should show allowed alert fields', () => {
- const fields = findTableKeys();
-
- expect(findTableField(fields, 'Iid').exists()).toBe(true);
- expect(findTableField(fields, 'Title').exists()).toBe(true);
- 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(true);
+ describe('default', () => {
+ beforeEach(mountComponent);
+
+ it('renders a table', () => {
+ expect(findTableComponent().exists()).toBe(true);
+ });
+
+ it('renders a cell based on alert data', () => {
+ expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token');
+ });
+
+ it('should show allowed alert fields', () => {
+ const fields = findTableKeys();
+ ['Iid', 'Title', 'Severity', 'Status', 'Hosts', 'Environment'].forEach((field) => {
+ expect(findTableField(fields, field).exists()).toBe(true);
+ });
+ });
+
+ it('should not show disallowed alert fields', () => {
+ const fields = findTableKeys();
+ ['Typename', 'Todos', 'Notes', 'Assignees'].forEach((field) => {
+ expect(findTableField(fields, field).exists()).toBe(false);
+ });
+ });
});
- it('should not show disallowed alert fields', () => {
- const fields = findTableKeys();
+ describe('environment', () => {
+ it('should display only the name for the environment', () => {
+ mountComponent();
+ expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName);
+ });
- 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);
- });
+ it('should not display the environment row if there is not data', () => {
+ environmentData = { name: null, path: null };
+ mountComponent();
- it('should display only the name for the environment', () => {
- expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName);
+ expect(findTableFieldValueByKey('Environment').text()).toBeFalsy();
+ });
});
- it('should not display the environment row if there is not data', () => {
- environmentData = { name: null, path: null };
- mountComponent();
-
- expect(findTableFieldValueByKey('Environment').text()).toBeFalsy();
+ describe('status', () => {
+ it('should show the translated status for the default statuses', () => {
+ mountComponent();
+ expect(findTableFieldValueByKey('Status').text()).toBe('Triggered');
+ });
+
+ it('should show the translated status for provided statuses', () => {
+ const translatedStatus = 'Test';
+ mountComponent({ statuses: { TRIGGERED: translatedStatus } });
+ expect(findTableFieldValueByKey('Status').text()).toBe(translatedStatus);
+ });
+
+ it('should show the provided status if value is not defined in statuses', () => {
+ mountComponent({ statuses: {} });
+ expect(findTableFieldValueByKey('Status').text()).toBe('TRIGGERED');
+ });
});
});
});
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 023895099b1..06753044e93 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
@@ -1,87 +1,88 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = `
-<div
- class="file-content code js-syntax-highlight"
- data-qa-selector="file_content"
->
+<div>
<div
- class="line-numbers"
+ class="file-content code js-syntax-highlight"
>
- <a
- class="diff-line-num js-line-number"
- data-line-number="1"
- href="#LC1"
- id="L1"
+ <div
+ class="line-numbers"
>
- <gl-icon-stub
- name="link"
- size="12"
- />
+ <a
+ class="diff-line-num js-line-number"
+ data-line-number="1"
+ href="#LC1"
+ id="L1"
+ >
+ <gl-icon-stub
+ name="link"
+ size="12"
+ />
+
+ 1
- 1
-
- </a>
- <a
- class="diff-line-num js-line-number"
- data-line-number="2"
- href="#LC2"
- id="L2"
- >
- <gl-icon-stub
- name="link"
- size="12"
- />
+ </a>
+ <a
+ class="diff-line-num js-line-number"
+ data-line-number="2"
+ href="#LC2"
+ id="L2"
+ >
+ <gl-icon-stub
+ name="link"
+ size="12"
+ />
+
+ 2
- 2
-
- </a>
- <a
- class="diff-line-num js-line-number"
- data-line-number="3"
- href="#LC3"
- id="L3"
- >
- <gl-icon-stub
- name="link"
- size="12"
- />
+ </a>
+ <a
+ class="diff-line-num js-line-number"
+ data-line-number="3"
+ href="#LC3"
+ id="L3"
+ >
+ <gl-icon-stub
+ name="link"
+ size="12"
+ />
+
+ 3
- 3
-
- </a>
- </div>
-
- <div
- class="blob-content"
- >
- <pre
- class="code highlight"
+ </a>
+ </div>
+
+ <div
+ class="blob-content"
>
- <code
- data-blob-hash="foo-bar"
+ <pre
+ class="code highlight"
>
- <span
- id="LC1"
+ <code
+ data-blob-hash="foo-bar"
>
- First
- </span>
-
+ <span
+ id="LC1"
+ >
+ First
+ </span>
+
- <span
- id="LC2"
- >
- Second
- </span>
-
+ <span
+ id="LC2"
+ >
+ Second
+ </span>
+
- <span
- id="LC3"
- >
- Third
- </span>
- </code>
- </pre>
+ <span
+ id="LC3"
+ >
+ Third
+ </span>
+ </code>
+ </pre>
+ </div>
</div>
</div>
`;
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 9a0616343fe..46d4edad891 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
@@ -1,20 +1,31 @@
import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants';
import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue';
+import EditorLite from '~/vue_shared/components/editor_lite.vue';
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) {
+ function createComponent(
+ content = contentMock,
+ isRawContent = false,
+ isRefactorFlagEnabled = false,
+ ) {
wrapper = shallowMount(SimpleViewer, {
provide: {
blobHash,
+ glFeatures: {
+ refactorBlobViewer: isRefactorFlagEnabled,
+ },
},
propsData: {
content,
type: 'text',
+ fileName: 'test.js',
+ isRawContent,
},
});
}
@@ -83,4 +94,32 @@ describe('Blob Simple Viewer component', () => {
});
});
});
+
+ describe('Vue refactoring to use Source Editor', () => {
+ const findEditorLite = () => wrapper.find(EditorLite);
+
+ it.each`
+ doesRender | condition | isRawContent | isRefactorFlagEnabled
+ ${'Does not'} | ${'rawContent is not specified'} | ${false} | ${true}
+ ${'Does not'} | ${'feature flag is disabled is not specified'} | ${true} | ${false}
+ ${'Does not'} | ${'both, the FF and rawContent are not specified'} | ${false} | ${false}
+ ${'Does'} | ${'both, the FF and rawContent are specified'} | ${true} | ${true}
+ `(
+ '$doesRender render Editor Lite component in readonly mode when $condition',
+ async ({ isRawContent, isRefactorFlagEnabled } = {}) => {
+ createComponent('raw content', isRawContent, isRefactorFlagEnabled);
+ await waitForPromises();
+
+ if (isRawContent && isRefactorFlagEnabled) {
+ expect(findEditorLite().exists()).toBe(true);
+
+ expect(findEditorLite().props('value')).toBe('raw content');
+ expect(findEditorLite().props('fileName')).toBe('test.js');
+ expect(findEditorLite().props('editorOptions')).toEqual({ readOnly: true });
+ } else {
+ expect(findEditorLite().exists()).toBe(false);
+ }
+ },
+ );
+ });
});
diff --git a/spec/frontend/vue_shared/components/delete_label_modal_spec.js b/spec/frontend/vue_shared/components/delete_label_modal_spec.js
new file mode 100644
index 00000000000..3905690dab4
--- /dev/null
+++ b/spec/frontend/vue_shared/components/delete_label_modal_spec.js
@@ -0,0 +1,64 @@
+import { GlModal } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { stubComponent } from 'helpers/stub_component';
+import { TEST_HOST } from 'helpers/test_constants';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue';
+
+const MOCK_MODAL_DATA = {
+ labelName: 'label 1',
+ subjectName: 'GitLab Org',
+ destroyPath: `${TEST_HOST}/1`,
+};
+
+describe('vue_shared/components/delete_label_modal', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = extendedWrapper(
+ mount(DeleteLabelModal, {
+ propsData: {
+ selector: '.js-test-btn',
+ },
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ template:
+ '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
+ }),
+ },
+ }),
+ );
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findModal = () => wrapper.find(GlModal);
+ const findPrimaryModalButton = () => wrapper.findByTestId('delete-button');
+
+ describe('template', () => {
+ describe('when modal data is set', () => {
+ beforeEach(() => {
+ createComponent();
+ wrapper.vm.labelName = MOCK_MODAL_DATA.labelName;
+ wrapper.vm.subjectName = MOCK_MODAL_DATA.subjectName;
+ wrapper.vm.destroyPath = MOCK_MODAL_DATA.destroyPath;
+ });
+
+ it('renders GlModal', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('displays the label name and subject name', () => {
+ expect(findModal().text()).toContain(
+ `${MOCK_MODAL_DATA.labelName} will be permanently deleted from ${MOCK_MODAL_DATA.subjectName}. This cannot be undone`,
+ );
+ });
+
+ it('passes the destroyPath to the button', () => {
+ expect(findPrimaryModalButton().attributes('href')).toBe(MOCK_MODAL_DATA.destroyPath);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/deprecated_modal_spec.js b/spec/frontend/vue_shared/components/deprecated_modal_spec.js
deleted file mode 100644
index b9793ce2d80..00000000000
--- a/spec/frontend/vue_shared/components/deprecated_modal_spec.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
-
-const modalComponent = Vue.extend(DeprecatedModal);
-
-describe('DeprecatedModal', () => {
- let vm;
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('props', () => {
- describe('without primaryButtonLabel', () => {
- beforeEach(() => {
- vm = mountComponent(modalComponent, {
- primaryButtonLabel: null,
- });
- });
-
- it('does not render a primary button', () => {
- expect(vm.$el.querySelector('.js-primary-button')).toBeNull();
- });
- });
-
- describe('with id', () => {
- describe('does not render a primary button', () => {
- beforeEach(() => {
- vm = mountComponent(modalComponent, {
- id: 'my-modal',
- });
- });
-
- it('assigns the id to the modal', () => {
- expect(vm.$el.querySelector('#my-modal.modal')).not.toBeNull();
- });
-
- it('does not show the modal immediately', () => {
- expect(vm.$el.querySelector('#my-modal.modal')).not.toHaveClass('show');
- });
-
- it('does not show a backdrop', () => {
- expect(vm.$el.querySelector('modal-backdrop')).toBeNull();
- });
- });
- });
-
- it('works with data-toggle="modal"', () => {
- setFixtures(`
- <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button>
- <div id="modal-container"></div>
- `);
-
- const modalContainer = document.getElementById('modal-container');
- const modalButton = document.getElementById('modal-button');
- vm = mountComponent(
- modalComponent,
- {
- id: 'my-modal',
- },
- modalContainer,
- );
- const modalElement = vm.$el.querySelector('#my-modal');
-
- expect(modalElement).not.toHaveClass('show');
-
- modalButton.click();
-
- expect(modalElement).toHaveClass('show');
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/ensure_data_spec.js b/spec/frontend/vue_shared/components/ensure_data_spec.js
new file mode 100644
index 00000000000..eef8b452f5f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/ensure_data_spec.js
@@ -0,0 +1,145 @@
+import { GlEmptyState } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { mount } from '@vue/test-utils';
+import ensureData from '~/ensure_data';
+
+const mockData = { message: 'Hello there' };
+const defaultOptions = {
+ parseData: () => mockData,
+ data: mockData,
+};
+
+const MockChildComponent = {
+ inject: ['message'],
+ render(createElement) {
+ return createElement('h1', this.message);
+ },
+};
+
+const MockParentComponent = {
+ components: {
+ MockChildComponent,
+ },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ otherProp: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ render(createElement) {
+ return createElement('div', [this.message, createElement(MockChildComponent)]);
+ },
+};
+
+describe('EnsureData', () => {
+ let wrapper;
+
+ function findEmptyState() {
+ return wrapper.findComponent(GlEmptyState);
+ }
+
+ function findChild() {
+ return wrapper.findComponent(MockChildComponent);
+ }
+ function findParent() {
+ return wrapper.findComponent(MockParentComponent);
+ }
+
+ function createComponent(options = defaultOptions) {
+ return mount(ensureData(MockParentComponent, options));
+ }
+
+ beforeEach(() => {
+ Sentry.captureException = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ Sentry.captureException.mockClear();
+ });
+
+ describe('when parseData throws', () => {
+ it('should render GlEmptyState', () => {
+ wrapper = createComponent({
+ parseData: () => {
+ throw new Error();
+ },
+ });
+
+ expect(findParent().exists()).toBe(false);
+ expect(findChild().exists()).toBe(false);
+ expect(findEmptyState().exists()).toBe(true);
+ });
+
+ it('should not log to Sentry when shouldLog=false (default)', () => {
+ wrapper = createComponent({
+ parseData: () => {
+ throw new Error();
+ },
+ });
+
+ expect(Sentry.captureException).not.toHaveBeenCalled();
+ });
+
+ it('should log to Sentry when shouldLog=true', () => {
+ const error = new Error('Error!');
+ wrapper = createComponent({
+ parseData: () => {
+ throw error;
+ },
+ shouldLog: true,
+ });
+
+ expect(Sentry.captureException).toHaveBeenCalledWith(error);
+ });
+ });
+
+ describe('when parseData succeeds', () => {
+ it('should render MockParentComponent and MockChildComponent', () => {
+ wrapper = createComponent();
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findParent().exists()).toBe(true);
+ expect(findChild().exists()).toBe(true);
+ });
+
+ it('enables user to provide data to child components', () => {
+ wrapper = createComponent();
+
+ const childComponent = findChild();
+ expect(childComponent.text()).toBe(mockData.message);
+ });
+
+ it('enables user to override provide data', () => {
+ const message = 'Another message';
+ wrapper = createComponent({ ...defaultOptions, provide: { message } });
+
+ const childComponent = findChild();
+ expect(childComponent.text()).toBe(message);
+ });
+
+ it('enables user to pass props to parent component', () => {
+ wrapper = createComponent();
+
+ expect(findParent().props()).toMatchObject(mockData);
+ });
+
+ it('enables user to override props data', () => {
+ const props = { message: 'Another message', otherProp: true };
+ wrapper = createComponent({ ...defaultOptions, props });
+
+ expect(findParent().props()).toMatchObject(props);
+ });
+
+ it('should not log to Sentry when shouldLog=true', () => {
+ wrapper = createComponent({ ...defaultOptions, shouldLog: true });
+
+ expect(Sentry.captureException).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
index 7606b3bd91c..c24528ba4d2 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
@@ -3,6 +3,8 @@ import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue
import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
+import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
+import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
@@ -59,6 +61,21 @@ export const mockMilestones = [
mockEscapedMilestone,
];
+export const mockEpics = [
+ { iid: 1, id: 1, title: 'Foo' },
+ { iid: 2, id: 2, title: 'Bar' },
+];
+
+export const mockEmoji1 = {
+ name: 'thumbsup',
+};
+
+export const mockEmoji2 = {
+ name: 'star',
+};
+
+export const mockEmojis = [mockEmoji1, mockEmoji2];
+
export const mockBranchToken = {
type: 'source_branch',
icon: 'branch',
@@ -103,6 +120,28 @@ export const mockMilestoneToken = {
fetchMilestones: () => Promise.resolve({ data: mockMilestones }),
};
+export const mockEpicToken = {
+ type: 'epic_iid',
+ icon: 'clock',
+ title: 'Epic',
+ unique: true,
+ symbol: '&',
+ token: EpicToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchEpics: () => Promise.resolve({ data: mockEpics }),
+ fetchSingleEpic: () => Promise.resolve({ data: mockEpics[0] }),
+};
+
+export const mockReactionEmojiToken = {
+ type: 'my_reaction_emoji',
+ icon: 'thumb-up',
+ title: 'My-Reaction',
+ unique: true,
+ token: EmojiToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchEmojis: () => Promise.resolve(mockEmojis),
+};
+
export const mockMembershipToken = {
type: 'with_inherited_permissions',
icon: 'group',
@@ -168,6 +207,14 @@ export const tokenValuePlain = {
value: { data: 'foo' },
};
+export const tokenValueEpic = {
+ type: 'epic_iid',
+ value: {
+ operator: '=',
+ data: '"foo"::&42',
+ },
+};
+
export const mockHistoryItems = [
[tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'],
[tokenValueAuthor, 'si'],
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
new file mode 100644
index 00000000000..231f2f01428
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
@@ -0,0 +1,217 @@
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchTokenSegment,
+ GlDropdownDivider,
+} from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+
+import {
+ DEFAULT_LABEL_NONE,
+ DEFAULT_LABEL_ANY,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
+
+import { mockReactionEmojiToken, mockEmojis } from '../mock_data';
+
+jest.mock('~/flash');
+const GlEmoji = { template: '<img/>' };
+const defaultStubs = {
+ Portal: true,
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+ GlEmoji,
+};
+
+function createComponent(options = {}) {
+ const {
+ config = mockReactionEmojiToken,
+ value = { data: '' },
+ active = false,
+ stubs = defaultStubs,
+ } = options;
+ return mount(EmojiToken, {
+ propsData: {
+ config,
+ value,
+ active,
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: 'custom-class',
+ },
+ stubs,
+ });
+}
+
+describe('EmojiToken', () => {
+ let mock;
+ let wrapper;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({ value: { data: mockEmojis[0].name } });
+
+ wrapper.setData({
+ emojis: mockEmojis,
+ });
+
+ await wrapper.vm.$nextTick();
+ });
+
+ describe('currentValue', () => {
+ it('returns lowercase string for `value.data`', () => {
+ expect(wrapper.vm.currentValue).toBe(mockEmojis[0].name);
+ });
+ });
+
+ describe('activeEmoji', () => {
+ it('returns object for currently present `value.data`', () => {
+ expect(wrapper.vm.activeEmoji).toEqual(mockEmojis[0]);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ describe('fetchEmojiBySearchTerm', () => {
+ it('calls `config.fetchEmojis` with provided searchTerm param', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchEmojis');
+
+ wrapper.vm.fetchEmojiBySearchTerm('foo');
+
+ expect(wrapper.vm.config.fetchEmojis).toHaveBeenCalledWith('foo');
+ });
+
+ it('sets response to `emojis` when request is successful', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockResolvedValue(mockEmojis);
+
+ wrapper.vm.fetchEmojiBySearchTerm('foo');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.emojis).toEqual(mockEmojis);
+ });
+ });
+
+ it('calls `createFlash` with flash error message when request fails', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
+
+ wrapper.vm.fetchEmojiBySearchTerm('foo');
+
+ return waitForPromises().then(() => {
+ expect(createFlash).toHaveBeenCalledWith('There was a problem fetching emojis.');
+ });
+ });
+
+ it('sets `loading` to false when request completes', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
+
+ wrapper.vm.fetchEmojiBySearchTerm('foo');
+
+ return waitForPromises().then(() => {
+ expect(wrapper.vm.loading).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('template', () => {
+ const defaultEmojis = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+
+ beforeEach(async () => {
+ wrapper = createComponent({
+ value: { data: `"${mockEmojis[0].name}"` },
+ });
+
+ wrapper.setData({
+ emojis: mockEmojis,
+ });
+
+ await wrapper.vm.$nextTick();
+ });
+
+ it('renders gl-filtered-search-token component', () => {
+ expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
+ });
+
+ it('renders token item when value is selected', () => {
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+
+ expect(tokenSegments).toHaveLength(3); // My Reaction, =, "thumbsup"
+ expect(tokenSegments.at(2).find(GlEmoji).attributes('data-name')).toEqual('thumbsup');
+ });
+
+ it('renders provided defaultEmojis as suggestions', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockReactionEmojiToken, defaultEmojis },
+ stubs: { Portal: true, GlEmoji },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(defaultEmojis.length);
+ defaultEmojis.forEach((emoji, index) => {
+ expect(suggestions.at(index).text()).toBe(emoji.text);
+ });
+ });
+
+ it('does not render divider when no defaultEmojis', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockReactionEmojiToken, defaultEmojis: [] },
+ stubs: { Portal: true, GlEmoji },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false);
+ expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
+ });
+
+ it('renders `DEFAULT_LABEL_NONE` and `DEFAULT_LABEL_ANY` as default suggestions', async () => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockReactionEmojiToken },
+ stubs: { Portal: true, GlEmoji },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await wrapper.vm.$nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(2);
+ expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_NONE.text);
+ expect(suggestions.at(1).text()).toBe(DEFAULT_LABEL_ANY.text);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js
new file mode 100644
index 00000000000..0c3f9e1363f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js
@@ -0,0 +1,180 @@
+import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+
+import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
+
+import { mockEpicToken, mockEpics } from '../mock_data';
+
+jest.mock('~/flash');
+
+const defaultStubs = {
+ Portal: true,
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+};
+
+function createComponent(options = {}) {
+ const {
+ config = mockEpicToken,
+ value = { data: '' },
+ active = false,
+ stubs = defaultStubs,
+ } = options;
+ return mount(EpicToken, {
+ propsData: {
+ config,
+ value,
+ active,
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: 'custom-class',
+ },
+ stubs,
+ });
+}
+
+describe('EpicToken', () => {
+ let mock;
+ let wrapper;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({
+ data: {
+ epics: mockEpics,
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+ });
+
+ describe('currentValue', () => {
+ it.each`
+ data | id
+ ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${mockEpics[0].iid}
+ ${mockEpics[0].iid} | ${mockEpics[0].iid}
+ ${'foobar'} | ${'foobar'}
+ `('$data returns $id', async ({ data, id }) => {
+ wrapper.setProps({ value: { data } });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.currentValue).toBe(id);
+ });
+ });
+
+ describe('activeEpic', () => {
+ it('returns object for currently present `value.data`', async () => {
+ wrapper.setProps({
+ value: { data: `${mockEpics[0].iid}` },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.activeEpic).toEqual(mockEpics[0]);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('fetchEpicsBySearchTerm', () => {
+ it('calls `config.fetchEpics` with provided searchTerm param', () => {
+ jest.spyOn(wrapper.vm.config, 'fetchEpics');
+
+ wrapper.vm.fetchEpicsBySearchTerm('foo');
+
+ expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith('foo');
+ });
+
+ it('sets response to `epics` when request is successful', async () => {
+ jest.spyOn(wrapper.vm.config, 'fetchEpics').mockResolvedValue({
+ data: mockEpics,
+ });
+
+ wrapper.vm.fetchEpicsBySearchTerm();
+
+ await waitForPromises();
+
+ expect(wrapper.vm.epics).toEqual(mockEpics);
+ });
+
+ it('calls `createFlash` with flash error message when request fails', async () => {
+ jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({});
+
+ wrapper.vm.fetchEpicsBySearchTerm('foo');
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was a problem fetching epics.',
+ });
+ });
+
+ it('sets `loading` to false when request completes', async () => {
+ jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({});
+
+ wrapper.vm.fetchEpicsBySearchTerm('foo');
+
+ await waitForPromises();
+
+ expect(wrapper.vm.loading).toBe(false);
+ });
+ });
+
+ describe('fetchSingleEpic', () => {
+ it('calls `config.fetchSingleEpic` with provided iid param', async () => {
+ jest.spyOn(wrapper.vm.config, 'fetchSingleEpic');
+
+ wrapper.vm.fetchSingleEpic(1);
+
+ expect(wrapper.vm.config.fetchSingleEpic).toHaveBeenCalledWith(1);
+
+ await waitForPromises();
+
+ expect(wrapper.vm.epics).toEqual([mockEpics[0]]);
+ });
+ });
+ });
+
+ describe('template', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({
+ value: { data: `${mockEpics[0].iid}` },
+ data: { epics: mockEpics },
+ });
+
+ await wrapper.vm.$nextTick();
+ });
+
+ it('renders gl-filtered-search-token component', () => {
+ expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
+ });
+
+ it('renders token item when value is selected', () => {
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+
+ expect(tokenSegments).toHaveLength(3);
+ expect(tokenSegments.at(2).text()).toBe(`${mockEpics[0].title}::&${mockEpics[0].iid}`);
+ });
+ });
+});
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 7676ce10ce0..8528c062426 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
@@ -118,6 +118,22 @@ describe('LabelToken', () => {
wrapper = createComponent();
});
+ describe('getLabelName', () => {
+ it('returns value of `name` or `title` property present in provided label param', () => {
+ let mockLabel = {
+ title: 'foo',
+ };
+
+ expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.title);
+
+ mockLabel = {
+ name: 'foo',
+ };
+
+ expect(wrapper.vm.getLabelName(mockLabel)).toBe(mockLabel.name);
+ });
+ });
+
describe('fetchLabelBySearchTerm', () => {
it('calls `config.fetchLabels` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchLabels');
diff --git a/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js b/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js
deleted file mode 100644
index ac670b622b1..00000000000
--- a/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import { GlToggle } from '@gitlab/ui';
-import { mount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-import GlToggleVuex from '~/vue_shared/components/gl_toggle_vuex.vue';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('GlToggleVuex component', () => {
- let wrapper;
- let store;
-
- const findButton = () => wrapper.find('button');
-
- const createWrapper = (props = {}) => {
- wrapper = mount(GlToggleVuex, {
- localVue,
- store,
- propsData: {
- stateProperty: 'toggleState',
- ...props,
- },
- });
- };
-
- beforeEach(() => {
- store = new Vuex.Store({
- state: {
- toggleState: false,
- },
- actions: {
- setToggleState: ({ commit }, { key, value }) => commit('setToggleState', { key, value }),
- },
- mutations: {
- setToggleState: (state, { key, value }) => {
- state[key] = value;
- },
- },
- });
- createWrapper();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders gl-toggle', () => {
- expect(wrapper.find(GlToggle).exists()).toBe(true);
- });
-
- it('properly computes default value for setAction', () => {
- expect(wrapper.props('setAction')).toBe('setToggleState');
- });
-
- describe('without a store module', () => {
- it('calls action with new value when value changes', () => {
- jest.spyOn(store, 'dispatch');
-
- findButton().trigger('click');
- expect(store.dispatch).toHaveBeenCalledWith('setToggleState', {
- key: 'toggleState',
- value: true,
- });
- });
-
- it('updates store property when value changes', () => {
- findButton().trigger('click');
- expect(store.state.toggleState).toBe(true);
- });
- });
-
- describe('with a store module', () => {
- beforeEach(() => {
- store = new Vuex.Store({
- modules: {
- someModule: {
- namespaced: true,
- state: {
- toggleState: false,
- },
- actions: {
- setToggleState: ({ commit }, { key, value }) =>
- commit('setToggleState', { key, value }),
- },
- mutations: {
- setToggleState: (state, { key, value }) => {
- state[key] = value;
- },
- },
- },
- },
- });
-
- createWrapper({
- storeModule: 'someModule',
- });
- });
-
- it('calls action with new value when value changes', () => {
- jest.spyOn(store, 'dispatch');
-
- findButton().trigger('click');
- expect(store.dispatch).toHaveBeenCalledWith('someModule/setToggleState', {
- key: 'toggleState',
- value: true,
- });
- });
-
- it('updates store property when value changes', () => {
- findButton().trigger('click');
- expect(store.state.someModule.toggleState).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js
index baf80a8a04e..30c6fa04032 100644
--- a/spec/frontend/vue_shared/components/help_popover_spec.js
+++ b/spec/frontend/vue_shared/components/help_popover_spec.js
@@ -27,7 +27,6 @@ describe('HelpPopover', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
it('renders a link button with an icon question', () => {
@@ -35,17 +34,12 @@ describe('HelpPopover', () => {
icon: 'question',
variant: 'link',
});
- expect(findQuestionButton().attributes().tabindex).toBe('0');
});
it('renders popover that uses the question button as target', () => {
expect(findPopover().props().target()).toBe(findQuestionButton().vm.$el);
});
- it('triggers popover on hover and focus', () => {
- expect(findPopover().props().triggers).toBe('hover focus');
- });
-
it('allows rendering title with HTML tags', () => {
expect(findPopover().find('strong').exists()).toBe(true);
});
@@ -54,6 +48,14 @@ describe('HelpPopover', () => {
expect(findPopover().find('b').exists()).toBe(true);
});
+ describe('without title', () => {
+ it('does not render title', () => {
+ buildWrapper({ title: null });
+
+ expect(findPopover().find('span').exists()).toBe(false);
+ });
+ });
+
it('binds other popover options to the popover instance', () => {
const placement = 'bottom';
diff --git a/spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js b/spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js
new file mode 100644
index 00000000000..f1c9fbb00c9
--- /dev/null
+++ b/spec/frontend/vue_shared/components/lib/utils/props_utils_spec.js
@@ -0,0 +1,91 @@
+import { propsUnion } from '~/vue_shared/components/lib/utils/props_utils';
+
+describe('propsUnion', () => {
+ const stringRequired = {
+ type: String,
+ required: true,
+ };
+
+ const stringOptional = {
+ type: String,
+ required: false,
+ };
+
+ const numberOptional = {
+ type: Number,
+ required: false,
+ };
+
+ const booleanRequired = {
+ type: Boolean,
+ required: true,
+ };
+
+ const FooComponent = {
+ props: { foo: stringRequired },
+ };
+
+ const BarComponent = {
+ props: { bar: numberOptional },
+ };
+
+ const FooBarComponent = {
+ props: {
+ foo: stringRequired,
+ bar: numberOptional,
+ },
+ };
+
+ const FooOptionalComponent = {
+ props: {
+ foo: stringOptional,
+ },
+ };
+
+ const QuxComponent = {
+ props: {
+ foo: booleanRequired,
+ qux: stringRequired,
+ },
+ };
+
+ it('returns an empty object given no components', () => {
+ expect(propsUnion([])).toEqual({});
+ });
+
+ it('merges non-overlapping props', () => {
+ expect(propsUnion([FooComponent, BarComponent])).toEqual({
+ ...FooComponent.props,
+ ...BarComponent.props,
+ });
+ });
+
+ it('merges overlapping props', () => {
+ expect(propsUnion([FooComponent, BarComponent, FooBarComponent])).toEqual({
+ ...FooComponent.props,
+ ...BarComponent.props,
+ ...FooBarComponent.props,
+ });
+ });
+
+ it.each`
+ components
+ ${[FooComponent, FooOptionalComponent]}
+ ${[FooOptionalComponent, FooComponent]}
+ `('prefers required props over non-required props', ({ components }) => {
+ expect(propsUnion(components)).toEqual(FooComponent.props);
+ });
+
+ it('throws if given props with conflicting types', () => {
+ expect(() => propsUnion([FooComponent, QuxComponent])).toThrow(/incompatible prop types/);
+ });
+
+ it.each`
+ components
+ ${[{ props: ['foo', 'bar'] }]}
+ ${[{ props: { foo: String, bar: Number } }]}
+ ${[{ props: { foo: {}, bar: {} } }]}
+ `('throw if given a non-verbose props object', ({ components }) => {
+ expect(() => propsUnion(components)).toThrow(/expected verbose prop/);
+ });
+});
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 5364e2d5f52..ba2450b56c9 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
@@ -1,5 +1,6 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ApplySuggestion from '~/vue_shared/components/markdown/apply_suggestion.vue';
import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
@@ -16,17 +17,14 @@ const DEFAULT_PROPS = {
describe('Suggestion Diff component', () => {
let wrapper;
- const createComponent = (props, glFeatures = {}) => {
+ const createComponent = (props) => {
wrapper = shallowMount(SuggestionDiffHeader, {
propsData: {
...DEFAULT_PROPS,
...props,
},
- provide: {
- glFeatures: {
- batchSuggestions: true,
- ...glFeatures,
- },
+ directives: {
+ GlTooltip: createMockDirective(),
},
});
};
@@ -211,18 +209,6 @@ describe('Suggestion Diff component', () => {
});
});
- describe('batchSuggestions feature flag is set to false', () => {
- beforeEach(() => {
- createComponent({}, { batchSuggestions: false });
- });
-
- it('disables add to batch buttons but keeps apply suggestion enabled', () => {
- expect(findApplyButton().exists()).toBe(true);
- expect(findAddToBatchButton().exists()).toBe(false);
- expect(findApplyButton().attributes('disabled')).not.toBe('true');
- });
- });
-
describe('canApply is set to false', () => {
beforeEach(() => {
createComponent({ canApply: false });
@@ -236,15 +222,23 @@ describe('Suggestion Diff component', () => {
});
describe('tooltip message for apply button', () => {
+ const findTooltip = () => getBinding(findApplyButton().element, 'gl-tooltip');
+
it('renders correct tooltip message when button is applicable', () => {
createComponent();
- expect(wrapper.vm.tooltipMessage).toBe('This also resolves this thread');
+ const tooltip = findTooltip();
+
+ expect(tooltip.modifiers.viewport).toBe(true);
+ expect(tooltip.value).toBe('This also resolves this thread');
});
it('renders the inapplicable reason in the tooltip when button is not applicable', () => {
const inapplicableReason = 'lorem';
createComponent({ canApply: false, inapplicableReason });
- expect(wrapper.vm.tooltipMessage).toBe(inapplicableReason);
+ const tooltip = findTooltip();
+
+ expect(tooltip.modifiers.viewport).toBe(true);
+ expect(tooltip.value).toBe(inapplicableReason);
});
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
index e7c31014bfc..eddc4033a65 100644
--- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
@@ -1,35 +1,75 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import toolbar from '~/vue_shared/components/markdown/toolbar.vue';
+import { mount } from '@vue/test-utils';
+import { isExperimentVariant } from '~/experimentation/utils';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
+import Toolbar from '~/vue_shared/components/markdown/toolbar.vue';
+
+jest.mock('~/experimentation/utils', () => ({ isExperimentVariant: jest.fn() }));
describe('toolbar', () => {
- let vm;
- const Toolbar = Vue.extend(toolbar);
- const props = {
- markdownDocsPath: '',
+ let wrapper;
+
+ const createMountedWrapper = (props = {}) => {
+ wrapper = mount(Toolbar, {
+ propsData: { markdownDocsPath: '', ...props },
+ stubs: { 'invite-members-trigger': true },
+ });
};
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ isExperimentVariant.mockReset();
});
describe('user can attach file', () => {
beforeEach(() => {
- vm = mountComponent(Toolbar, props);
+ createMountedWrapper();
});
it('should render uploading-container', () => {
- expect(vm.$el.querySelector('.uploading-container')).not.toBeNull();
+ expect(wrapper.vm.$el.querySelector('.uploading-container')).not.toBeNull();
});
});
describe('user cannot attach file', () => {
beforeEach(() => {
- vm = mountComponent(Toolbar, { ...props, canAttachFile: false });
+ createMountedWrapper({ canAttachFile: false });
});
it('should not render uploading-container', () => {
- expect(vm.$el.querySelector('.uploading-container')).toBeNull();
+ expect(wrapper.vm.$el.querySelector('.uploading-container')).toBeNull();
+ });
+ });
+
+ describe('user can invite member', () => {
+ const findInviteLink = () => wrapper.find(InviteMembersTrigger);
+
+ beforeEach(() => {
+ isExperimentVariant.mockReturnValue(true);
+ createMountedWrapper();
+ });
+
+ it('should render the invite members trigger', () => {
+ expect(findInviteLink().exists()).toBe(true);
+ });
+
+ it('should have correct props', () => {
+ expect(findInviteLink().props().displayText).toBe('Invite Member');
+ expect(findInviteLink().props().trackExperiment).toBe(INVITE_MEMBERS_IN_COMMENT);
+ expect(findInviteLink().props().triggerSource).toBe(INVITE_MEMBERS_IN_COMMENT);
+ });
+ });
+
+ describe('user can not invite member', () => {
+ const findInviteLink = () => wrapper.find(InviteMembersTrigger);
+
+ beforeEach(() => {
+ isExperimentVariant.mockReturnValue(false);
+ createMountedWrapper();
+ });
+
+ it('should render the invite members trigger', () => {
+ expect(findInviteLink().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js b/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js
deleted file mode 100644
index d86d627886f..00000000000
--- a/spec/frontend/vue_shared/components/recaptcha_eventhub_spec.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { eventHub, callbackName } from '~/vue_shared/components/recaptcha_eventhub';
-
-describe('reCAPTCHA event hub', () => {
- // the following test case currently crashes
- // see https://gitlab.com/gitlab-org/gitlab/issues/29192#note_217840035
- // eslint-disable-next-line jest/no-disabled-tests
- it.skip('throws an error for overriding the callback', () => {
- expect(() => {
- window[callbackName] = 'something';
- }).toThrow();
- });
-
- it('triggering callback emits a submit event', () => {
- const eventHandler = jest.fn();
- eventHub.$once('submit', eventHandler);
-
- window[callbackName]();
-
- expect(eventHandler).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend/vue_shared/components/recaptcha_modal_spec.js b/spec/frontend/vue_shared/components/recaptcha_modal_spec.js
deleted file mode 100644
index 8ab65efd388..00000000000
--- a/spec/frontend/vue_shared/components/recaptcha_modal_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-
-import { eventHub } from '~/vue_shared/components/recaptcha_eventhub';
-
-import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue';
-
-describe('RecaptchaModal', () => {
- const recaptchaFormId = 'recaptcha-form';
- const recaptchaHtml = `<form id="${recaptchaFormId}"></form>`;
-
- let wrapper;
-
- const findRecaptchaForm = () => wrapper.find(`#${recaptchaFormId}`).element;
-
- beforeEach(() => {
- wrapper = shallowMount(RecaptchaModal, {
- propsData: {
- html: recaptchaHtml,
- },
- });
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('submits the form if event hub emits submit event', () => {
- const form = findRecaptchaForm();
- jest.spyOn(form, 'submit').mockImplementation();
-
- eventHub.$emit('submit');
-
- expect(form.submit).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend/vue_shared/components/registry/registry_search_spec.js b/spec/frontend/vue_shared/components/registry/registry_search_spec.js
index 28bdb275756..f5ef5b3d443 100644
--- a/spec/frontend/vue_shared/components/registry/registry_search_spec.js
+++ b/spec/frontend/vue_shared/components/registry/registry_search_spec.js
@@ -1,5 +1,6 @@
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import component from '~/vue_shared/components/registry/registry_search.vue';
describe('Registry Search', () => {
@@ -12,8 +13,18 @@ describe('Registry Search', () => {
const defaultProps = {
filter: [],
sorting: { sort: 'asc', orderBy: 'name' },
- tokens: ['foo'],
- sortableFields: [{ label: 'name', orderBy: 'name' }, { label: 'baz' }],
+ tokens: [{ type: 'foo' }],
+ sortableFields: [
+ { label: 'name', orderBy: 'name' },
+ { label: 'baz', orderBy: 'bar' },
+ ],
+ };
+
+ const defaultQueryChangedPayload = {
+ foo: '',
+ orderBy: 'name',
+ search: [],
+ sort: 'asc',
};
const mountComponent = (propsData = defaultProps) => {
@@ -55,20 +66,22 @@ describe('Registry Search', () => {
expect(wrapper.emitted('filter:changed')).toEqual([['foo']]);
});
- it('emits filter:submit on submit event', () => {
+ it('emits filter:submit and query:changed on submit event', () => {
mountComponent();
findFilteredSearch().vm.$emit('submit');
expect(wrapper.emitted('filter:submit')).toEqual([[]]);
+ expect(wrapper.emitted('query:changed')).toEqual([[defaultQueryChangedPayload]]);
});
- it('emits filter:changed and filter:submit on clear event', () => {
+ it('emits filter:changed, filter:submit and query:changed on clear event', () => {
mountComponent();
findFilteredSearch().vm.$emit('clear');
expect(wrapper.emitted('filter:changed')).toEqual([[[]]]);
expect(wrapper.emitted('filter:submit')).toEqual([[]]);
+ expect(wrapper.emitted('query:changed')).toEqual([[defaultQueryChangedPayload]]);
});
it('binds tokens prop', () => {
@@ -90,15 +103,47 @@ describe('Registry Search', () => {
findPackageListSorting().vm.$emit('sortDirectionChange');
expect(wrapper.emitted('sorting:changed')).toEqual([[{ sort: 'desc' }]]);
+ expect(wrapper.emitted('query:changed')).toEqual([
+ [{ ...defaultQueryChangedPayload, sort: 'desc' }],
+ ]);
});
it('on sort item click emits sorting:changed event ', () => {
mountComponent();
- findSortingItems().at(0).vm.$emit('click');
+ findSortingItems().at(1).vm.$emit('click');
expect(wrapper.emitted('sorting:changed')).toEqual([
- [{ orderBy: defaultProps.sortableFields[0].orderBy }],
+ [{ orderBy: defaultProps.sortableFields[1].orderBy }],
+ ]);
+ expect(wrapper.emitted('query:changed')).toEqual([
+ [{ ...defaultQueryChangedPayload, orderBy: 'bar' }],
+ ]);
+ });
+ });
+
+ describe('query string calculation', () => {
+ const filter = [
+ { type: FILTERED_SEARCH_TERM, value: { data: 'one' } },
+ { type: FILTERED_SEARCH_TERM, value: { data: 'two' } },
+ { type: 'typeOne', value: { data: 'value_one' } },
+ { type: 'typeTwo', value: { data: 'value_two' } },
+ ];
+
+ it('aggregates the filter in the correct object', () => {
+ mountComponent({ ...defaultProps, filter });
+
+ findFilteredSearch().vm.$emit('submit');
+
+ expect(wrapper.emitted('query:changed')).toEqual([
+ [
+ {
+ ...defaultQueryChangedPayload,
+ search: ['one', 'two'],
+ typeOne: 'value_one',
+ typeTwo: 'value_two',
+ },
+ ],
]);
});
});
diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/vue_shared/components/remove_member_modal_spec.js
index 78fe6d53eee..ce9de28d53c 100644
--- a/spec/frontend/vue_shared/components/remove_member_modal_spec.js
+++ b/spec/frontend/vue_shared/components/remove_member_modal_spec.js
@@ -1,13 +1,25 @@
-import { GlFormCheckbox, GlModal } from '@gitlab/ui';
+import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
+const mockSchedules = JSON.stringify({
+ schedules: [
+ {
+ id: 1,
+ name: 'Schedule 1',
+ },
+ ],
+ name: 'User1',
+});
+
describe('RemoveMemberModal', () => {
const memberPath = '/gitlab-org/gitlab-test/-/project_members/90';
let wrapper;
const findForm = () => wrapper.find({ ref: 'form' });
- const findGlModal = () => wrapper.find(GlModal);
+ const findGlModal = () => wrapper.findComponent(GlModal);
+ const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList);
afterEach(() => {
wrapper.destroy();
@@ -15,26 +27,43 @@ describe('RemoveMemberModal', () => {
});
describe.each`
- state | isAccessRequest | actionText | checkboxTestDescription | checkboxExpected | message
- ${'removing a member'} | ${'false'} | ${'Remove member'} | ${'shows a checkbox to allow removal from related issues and MRs'} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'}
- ${'denying an access request'} | ${'true'} | ${'Deny access request'} | ${'does not show a checkbox'} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"}
+ state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules
+ ${'removing a group member'} | ${'GroupMember'} | ${false} | ${'false'} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${`{}`}
+ ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${'false'} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
+ ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${'false'} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${`{}`}
+ ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${'true'} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules}
`(
'when $state',
- ({ actionText, isAccessRequest, message, checkboxTestDescription, checkboxExpected }) => {
+ ({
+ actionText,
+ memberType,
+ isAccessRequest,
+ isInvite,
+ message,
+ removeSubMembershipsCheckboxExpected,
+ unassignIssuablesCheckboxExpected,
+ onCallSchedules,
+ }) => {
beforeEach(() => {
wrapper = shallowMount(RemoveMemberModal, {
data() {
return {
modalData: {
isAccessRequest,
+ isInvite,
message,
memberPath,
+ memberType,
+ onCallSchedules,
},
};
},
});
});
+ const parsedSchedules = JSON.parse(onCallSchedules);
+ const isPartOfOncallSchedules = Boolean(isAccessRequest && parsedSchedules.schedules?.length);
+
it(`has the title ${actionText}`, () => {
expect(findGlModal().attributes('title')).toBe(actionText);
});
@@ -47,8 +76,24 @@ describe('RemoveMemberModal', () => {
expect(wrapper.find('[data-testid=modal-message]').text()).toBe(message);
});
- it(`${checkboxTestDescription}`, () => {
- expect(wrapper.find(GlFormCheckbox).exists()).toBe(checkboxExpected);
+ it(`shows ${
+ removeSubMembershipsCheckboxExpected ? 'a' : 'no'
+ } checkbox to remove direct memberships of subgroups/projects`, () => {
+ expect(wrapper.find('[name=remove_sub_memberships]').exists()).toBe(
+ removeSubMembershipsCheckboxExpected,
+ );
+ });
+
+ it(`shows ${
+ unassignIssuablesCheckboxExpected ? 'a' : 'no'
+ } checkbox to allow removal from related issues and MRs`, () => {
+ expect(wrapper.find('[name=unassign_issuables]').exists()).toBe(
+ unassignIssuablesCheckboxExpected,
+ );
+ });
+
+ it(`shows ${isPartOfOncallSchedules ? 'all' : 'no'} related on-call schedules`, () => {
+ expect(findOnCallSchedulesList().exists()).toBe(isPartOfOncallSchedules);
});
it('submits the form when the modal is submitted', () => {
diff --git a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js
index 01f7f3d49c7..bc1545014d7 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js
@@ -98,9 +98,21 @@ 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",
+ '# Install and run as service\nsudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner\nsudo gitlab-runner start',
registerInstructions:
- 'sudo gitlab-runner register --url http://192.168.1.81:3000/ --registration-token GE5gsjeep_HAtBf9s3Yz',
+ 'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token $REGISTRATION_TOKEN',
+ __typename: 'RunnerSetup',
+ },
+ },
+};
+
+export const mockGraphqlInstructionsWindows = {
+ data: {
+ runnerSetup: {
+ installInstructions:
+ '# Windows runner, then run\n.gitlab-runner.exe install\n.gitlab-runner.exe start',
+ registerInstructions:
+ './gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token $REGISTRATION_TOKEN',
__typename: 'RunnerSetup',
},
},
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
new file mode 100644
index 00000000000..4033c943b82
--- /dev/null
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
@@ -0,0 +1,184 @@
+import { GlAlert, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import getRunnerPlatformsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql';
+import getRunnerSetupInstructionsQuery from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql';
+import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+
+import {
+ mockGraphqlRunnerPlatforms,
+ mockGraphqlInstructions,
+ mockGraphqlInstructionsWindows,
+} from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('RunnerInstructionsModal component', () => {
+ let wrapper;
+ let fakeApollo;
+ let runnerPlatformsHandler;
+ let runnerSetupInstructionsHandler;
+
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findPlatformButtons = () => wrapper.findAllByTestId('platform-button');
+ const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item');
+ const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions');
+ const findRegisterCommand = () => wrapper.findByTestId('register-command');
+
+ const createComponent = () => {
+ const requestHandlers = [
+ [getRunnerPlatformsQuery, runnerPlatformsHandler],
+ [getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler],
+ ];
+
+ fakeApollo = createMockApollo(requestHandlers);
+
+ wrapper = extendedWrapper(
+ shallowMount(RunnerInstructionsModal, {
+ propsData: {
+ modalId: 'runner-instructions-modal',
+ },
+ localVue,
+ apolloProvider: fakeApollo,
+ }),
+ );
+ };
+
+ beforeEach(async () => {
+ runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms);
+ runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions);
+
+ createComponent();
+
+ await nextTick();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should not show alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('should contain a number of platforms buttons', () => {
+ expect(runnerPlatformsHandler).toHaveBeenCalledWith({});
+
+ const buttons = findPlatformButtons();
+
+ expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length);
+ });
+
+ it('should contain a number of dropdown items for the architecture options', () => {
+ expect(findArchitectureDropdownItems()).toHaveLength(
+ mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length,
+ );
+ });
+
+ describe('should display default instructions', () => {
+ const { installInstructions, registerInstructions } = mockGraphqlInstructions.data.runnerSetup;
+
+ it('runner instructions are requested', () => {
+ expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({
+ platform: 'linux',
+ architecture: 'amd64',
+ });
+ });
+
+ it('binary instructions are shown', () => {
+ const instructions = findBinaryInstructions().text();
+
+ expect(instructions).toBe(installInstructions);
+ });
+
+ it('register command is shown', () => {
+ const instructions = findRegisterCommand().text();
+
+ expect(instructions).toBe(registerInstructions);
+ });
+ });
+
+ describe('after a platform and architecture are selected', () => {
+ const {
+ installInstructions,
+ registerInstructions,
+ } = mockGraphqlInstructionsWindows.data.runnerSetup;
+
+ beforeEach(async () => {
+ runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows);
+
+ findPlatformButtons().at(2).vm.$emit('click'); // another option, happens to be windows
+ await nextTick();
+
+ findArchitectureDropdownItems().at(1).vm.$emit('click'); // another option
+ await nextTick();
+ });
+
+ it('runner instructions are requested', () => {
+ expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({
+ platform: 'windows',
+ architecture: '386',
+ });
+ });
+
+ it('other binary instructions are shown', () => {
+ const instructions = findBinaryInstructions().text();
+
+ expect(instructions).toBe(installInstructions);
+ });
+
+ it('register command is shown', () => {
+ const command = findRegisterCommand().text();
+
+ expect(command).toBe(registerInstructions);
+ });
+ });
+
+ describe('when apollo is loading', () => {
+ it('should show a skeleton loader', async () => {
+ createComponent();
+ expect(findSkeletonLoader().exists()).toBe(true);
+ expect(findGlLoadingIcon().exists()).toBe(false);
+
+ await nextTick(); // wait for platforms
+
+ expect(findGlLoadingIcon().exists()).toBe(true);
+ });
+
+ it('once loaded, should not show a loading state', async () => {
+ createComponent();
+
+ await nextTick(); // wait for platforms
+ await nextTick(); // wait for architectures
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ expect(findGlLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('when instructions cannot be loaded', () => {
+ beforeEach(async () => {
+ runnerSetupInstructionsHandler.mockRejectedValue();
+
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('should show alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('should not show instructions', () => {
+ expect(findBinaryInstructions().exists()).toBe(false);
+ expect(findRegisterCommand().exists()).toBe(false);
+ });
+ });
+});
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
index 48db60bfd33..23f8d6afcb5 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js
@@ -1,113 +1,41 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
-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 { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
-
-import { mockGraphqlRunnerPlatforms, mockGraphqlInstructions } from './mock_data';
-
-const projectPath = 'gitlab-org/gitlab';
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
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(async () => {
- const requestHandlers = [
- [getRunnerPlatforms, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)],
- [getRunnerSetupInstructions, jest.fn().mockResolvedValue(mockGraphqlInstructions)],
- ];
+ const findModalButton = () => wrapper.findByTestId('show-modal-button');
+ const findModal = () => wrapper.findComponent(RunnerInstructionsModal);
- fakeApollo = createMockApollo(requestHandlers);
+ const createComponent = () => {
+ wrapper = extendedWrapper(shallowMount(RunnerInstructions));
+ };
- wrapper = shallowMount(RunnerInstructions, {
- provide: {
- projectPath,
- },
- localVue,
- apolloProvider: fakeApollo,
- });
-
- await wrapper.vm.$nextTick();
+ beforeEach(() => {
+ createComponent();
});
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,
- );
- });
+ expect(findModalButton().exists()).toBe(true);
+ expect(findModalButton().text()).toBe('Show Runner installation instructions');
});
- 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()).toMatch('sudo chmod +x /usr/local/bin/gitlab-runner');
- expect(runner.text()).toMatch(
- `sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash`,
- );
- expect(runner.text()).toMatch(
- 'sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner',
- );
- expect(runner.text()).toMatch('sudo gitlab-runner start');
+ it('should not render the modal once mounted', () => {
+ expect(findModal().exists()).toBe(false);
});
- 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();
+ it('should render the modal once clicked', async () => {
+ findModalButton().vm.$emit('click');
- const runner = findRunnerInstructionsSection();
+ await nextTick();
- expect(runner.text()).toMatch(mockGraphqlInstructions.data.runnerSetup.registerInstructions);
+ expect(findModal().exists()).toBe(true);
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js b/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js
new file mode 100644
index 00000000000..b99b1a66b79
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js
@@ -0,0 +1,74 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue';
+
+describe('SidebarCopyableField', () => {
+ let wrapper;
+
+ const defaultProps = {
+ value: 'Gl-1',
+ name: 'Reference',
+ };
+
+ const createComponent = (propsData = defaultProps) => {
+ wrapper = shallowMount(CopyableField, {
+ propsData,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ describe('template', () => {
+ describe('when `isLoading` prop is `false`', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders copyable field', () => {
+ expect(wrapper.text()).toContain('Reference: Gl-1');
+ });
+
+ it('renders ClipboardButton with correct props', () => {
+ const clipboardButton = findClipboardButton();
+
+ expect(clipboardButton.exists()).toBe(true);
+ expect(clipboardButton.props('title')).toBe(`Copy ${defaultProps.name}`);
+ expect(clipboardButton.props('text')).toBe(defaultProps.value);
+ });
+
+ it('does not render loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('when `isLoading` prop is `true`', () => {
+ beforeEach(() => {
+ createComponent({ ...defaultProps, isLoading: true });
+ });
+
+ it('renders loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findLoadingIcon().props('label')).toBe('Loading Reference');
+ });
+
+ it('does not render clipboard button', () => {
+ expect(findClipboardButton().exists()).toBe(false);
+ });
+ });
+
+ describe('with `clipboardTooltipText` prop', () => {
+ it('sets ClipboardButton `title` prop to `clipboardTooltipText` value', () => {
+ const mockClipboardTooltipText = 'Copy my custom value';
+ createComponent({ ...defaultProps, clipboardTooltipText: mockClipboardTooltipText });
+
+ expect(findClipboardButton().props('title')).toBe(mockClipboardTooltipText);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/url_sync_spec.js b/spec/frontend/vue_shared/components/url_sync_spec.js
new file mode 100644
index 00000000000..86bbc146c5f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/url_sync_spec.js
@@ -0,0 +1,97 @@
+import { shallowMount } from '@vue/test-utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { historyPushState } from '~/lib/utils/common_utils';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import UrlSyncComponent from '~/vue_shared/components/url_sync.vue';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ mergeUrlParams: jest.fn((query, url) => `urlParams: ${query} ${url}`),
+}));
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ historyPushState: jest.fn(),
+}));
+
+describe('url sync component', () => {
+ let wrapper;
+ const mockQuery = { group_id: '5014437163714', project_ids: ['5014437608314'] };
+ const TEST_HOST = 'http://testhost/';
+
+ setWindowLocation(TEST_HOST);
+
+ const findButton = () => wrapper.find('button');
+
+ const createComponent = ({ query = mockQuery, scopedSlots, slots } = {}) => {
+ wrapper = shallowMount(UrlSyncComponent, {
+ propsData: { query },
+ scopedSlots,
+ slots,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const expectUrlSync = (query, times, mergeUrlParamsReturnValue) => {
+ expect(mergeUrlParams).toHaveBeenCalledTimes(times);
+ expect(mergeUrlParams).toHaveBeenCalledWith(query, TEST_HOST, { spreadArrays: true });
+
+ expect(historyPushState).toHaveBeenCalledTimes(times);
+ expect(historyPushState).toHaveBeenCalledWith(mergeUrlParamsReturnValue);
+ };
+
+ describe('with query as a props', () => {
+ it('immediately syncs the query to the URL', () => {
+ createComponent();
+
+ expectUrlSync(mockQuery, 1, mergeUrlParams.mock.results[0].value);
+ });
+
+ describe('when the query is modified', () => {
+ const newQuery = { foo: true };
+
+ it('updates the URL with the new query', async () => {
+ createComponent();
+ // using setProps to test the watcher
+ await wrapper.setProps({ query: newQuery });
+
+ expectUrlSync(mockQuery, 2, mergeUrlParams.mock.results[1].value);
+ });
+ });
+ });
+
+ describe('with scoped slot', () => {
+ const scopedSlots = {
+ default: `
+ <button @click="props.updateQuery({bar: 'baz'})">Update Query </button>
+ `,
+ };
+
+ it('renders the scoped slot', () => {
+ createComponent({ query: null, scopedSlots });
+
+ expect(findButton().exists()).toBe(true);
+ });
+
+ it('syncs the url with the scoped slots function', () => {
+ createComponent({ query: null, scopedSlots });
+
+ findButton().trigger('click');
+
+ expectUrlSync({ bar: 'baz' }, 1, mergeUrlParams.mock.results[0].value);
+ });
+ });
+
+ describe('with slot', () => {
+ const slots = {
+ default: '<button>Normal Slot</button>',
+ };
+
+ it('renders the default slot', () => {
+ createComponent({ query: null, slots });
+
+ expect(findButton().exists()).toBe(true);
+ });
+ });
+});
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 184a1e458b5..87fe8619f28 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,4 +1,4 @@
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui';
+import { GlSkeletonLoader, GlSprintf, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
@@ -52,7 +52,7 @@ describe('User Popover Component', () => {
};
describe('when user is loading', () => {
- it('displays skeleton loaders', () => {
+ it('displays skeleton loader', () => {
createWrapper({
user: {
name: null,
@@ -65,7 +65,7 @@ describe('User Popover Component', () => {
},
});
- expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(4);
+ expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true);
});
});
diff --git a/spec/frontend/vue_shared/oncall_schedules_list_spec.js b/spec/frontend/vue_shared/oncall_schedules_list_spec.js
new file mode 100644
index 00000000000..5c30809c09b
--- /dev/null
+++ b/spec/frontend/vue_shared/oncall_schedules_list_spec.js
@@ -0,0 +1,87 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
+
+const mockSchedules = [
+ {
+ name: 'Schedule 1',
+ scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules',
+ projectName: 'Shell',
+ projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/',
+ },
+ {
+ name: 'Schedule 2',
+ scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules',
+ projectName: 'UI',
+ projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/',
+ },
+];
+
+const userName = 'User 1';
+
+describe('On-call schedules list', () => {
+ let wrapper;
+
+ function createComponent(props) {
+ wrapper = extendedWrapper(
+ shallowMount(OncallSchedulesList, {
+ propsData: {
+ schedules: mockSchedules,
+ userName,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ }),
+ );
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findLinks = () => wrapper.findAllComponents(GlLink);
+ const findTitle = () => wrapper.findByTestId('title');
+ const findFooter = () => wrapper.findByTestId('footer');
+ const findSchedules = () => wrapper.findByTestId('schedules-list');
+
+ describe.each`
+ isCurrentUser | titleText | footerText
+ ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'}
+ ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'}
+ `('when current user ', ({ isCurrentUser, titleText, footerText }) => {
+ it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call schedule`, async () => {
+ createComponent({
+ isCurrentUser,
+ });
+
+ expect(findTitle().text()).toBe(titleText);
+ expect(findFooter().text()).toBe(footerText);
+ });
+ });
+
+ describe.each(mockSchedules)(
+ 'renders each on-call schedule data',
+ ({ name, scheduleUrl, projectName, projectUrl }) => {
+ beforeEach(() => {
+ createComponent({ schedules: [{ name, scheduleUrl, projectName, projectUrl }] });
+ });
+
+ it(`renders schedule ${name}'s name and link`, () => {
+ const msg = findSchedules().text();
+
+ expect(msg).toContain(`On-call schedule ${name}`);
+ expect(findLinks().at(0).attributes('href')).toBe(scheduleUrl);
+ });
+
+ it(`renders project ${projectName}'s name and link`, () => {
+ const msg = findSchedules().text();
+
+ expect(msg).toContain(`in Project ${projectName}`);
+ expect(findLinks().at(1).attributes('href')).toBe(projectUrl);
+ });
+ },
+ );
+});