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/components/__snapshots__/awards_list_spec.js.snap191
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap4
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap4
-rw-r--r--spec/frontend/vue_shared/components/awards_list_spec.js36
-rw-r--r--spec/frontend/vue_shared/components/callout_spec.js63
-rw-r--r--spec/frontend/vue_shared/components/clipboard_button_spec.js42
-rw-r--r--spec/frontend/vue_shared/components/color_picker/color_picker_spec.js140
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js39
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js24
-rw-r--r--spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap47
-rw-r--r--spec/frontend/vue_shared/components/gfm_autocomplete/gfm_autocomplete_spec.js (renamed from spec/frontend/vue_shared/components/gl_mentions_spec.js)10
-rw-r--r--spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js344
-rw-r--r--spec/frontend/vue_shared/components/issue/issue_milestone_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/loading_button_spec.js100
-rw-r--r--spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js72
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js108
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js74
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js85
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js64
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js66
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js66
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js89
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js46
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js115
-rw-r--r--spec/frontend/vue_shared/components/members/mock_data.js71
-rw-r--r--spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js106
-rw-r--r--spec/frontend/vue_shared/components/members/table/created_at_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js166
-rw-r--r--spec/frontend/vue_shared/components/members/table/expires_at_spec.js86
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js43
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_avatar_spec.js39
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_source_spec.js71
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js251
-rw-r--r--spec/frontend/vue_shared/components/members/table/members_table_spec.js212
-rw-r--r--spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js151
-rw-r--r--spec/frontend/vue_shared/components/members/utils_spec.js122
-rw-r--r--spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap1
-rw-r--r--spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap6
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap144
-rw-r--r--spec/frontend/vue_shared/components/security_reports/help_icon_spec.js68
-rw-r--r--spec/frontend/vue_shared/components/security_reports/security_summary_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js12
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js104
-rw-r--r--spec/frontend/vue_shared/components/toggle_button_spec.js107
-rw-r--r--spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js239
-rw-r--r--spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js64
-rw-r--r--spec/frontend/vue_shared/security_reports/mock_data.js437
-rw-r--r--spec/frontend/vue_shared/security_reports/security_reports_app_spec.js497
-rw-r--r--spec/frontend/vue_shared/security_reports/store/getters_spec.js182
-rw-r--r--spec/frontend/vue_shared/security_reports/utils_spec.js28
55 files changed, 2635 insertions, 2732 deletions
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 04ae2a0f34d..20ea897e29c 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
@@ -5,12 +5,17 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="awards js-awards-block"
>
<button
- class="btn award-control"
+ class="btn gl-mr-3 btn-default btn-md gl-button"
data-testid="award-button"
title="Ada, Leonardo, and Marie"
type="button"
>
+ <!---->
+
+ <!---->
+
<span
+ class="award-emoji-block"
data-testid="award-html"
>
@@ -23,18 +28,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</span>
<span
- class="award-control-text js-counter"
+ class="gl-button-text"
>
- 3
+
+ <span
+ class="js-counter"
+ >
+ 3
+ </span>
</span>
</button>
<button
- class="btn award-control active"
+ class="btn gl-mr-3 btn-default btn-md gl-button selected"
data-testid="award-button"
title="You, Ada, and Marie"
type="button"
>
+ <!---->
+
+ <!---->
+
<span
+ class="award-emoji-block"
data-testid="award-html"
>
@@ -47,18 +62,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</span>
<span
- class="award-control-text js-counter"
+ class="gl-button-text"
>
- 3
+
+ <span
+ class="js-counter"
+ >
+ 3
+ </span>
</span>
</button>
<button
- class="btn award-control"
+ class="btn gl-mr-3 btn-default btn-md gl-button"
data-testid="award-button"
title="Ada and Jane"
type="button"
>
+ <!---->
+
+ <!---->
+
<span
+ class="award-emoji-block"
data-testid="award-html"
>
@@ -71,18 +96,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</span>
<span
- class="award-control-text js-counter"
+ class="gl-button-text"
>
- 2
+
+ <span
+ class="js-counter"
+ >
+ 2
+ </span>
</span>
</button>
<button
- class="btn award-control active"
+ class="btn gl-mr-3 btn-default btn-md gl-button selected"
data-testid="award-button"
title="You, Ada, Jane, and Leonardo"
type="button"
>
+ <!---->
+
+ <!---->
+
<span
+ class="award-emoji-block"
data-testid="award-html"
>
@@ -95,18 +130,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</span>
<span
- class="award-control-text js-counter"
+ class="gl-button-text"
>
- 4
+
+ <span
+ class="js-counter"
+ >
+ 4
+ </span>
</span>
</button>
<button
- class="btn award-control active"
+ class="btn gl-mr-3 btn-default btn-md gl-button selected"
data-testid="award-button"
title="You"
type="button"
>
+ <!---->
+
+ <!---->
+
<span
+ class="award-emoji-block"
data-testid="award-html"
>
@@ -119,18 +164,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</span>
<span
- class="award-control-text js-counter"
+ class="gl-button-text"
>
- 1
+
+ <span
+ class="js-counter"
+ >
+ 1
+ </span>
</span>
</button>
<button
- class="btn award-control"
+ class="btn gl-mr-3 btn-default btn-md gl-button"
data-testid="award-button"
title="Marie"
type="button"
>
+ <!---->
+
+ <!---->
+
<span
+ class="award-emoji-block"
data-testid="award-html"
>
@@ -143,18 +198,28 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</span>
<span
- class="award-control-text js-counter"
+ class="gl-button-text"
>
- 1
+
+ <span
+ class="js-counter"
+ >
+ 1
+ </span>
</span>
</button>
<button
- class="btn award-control active"
+ class="btn gl-mr-3 btn-default btn-md gl-button selected"
data-testid="award-button"
title="You"
type="button"
>
+ <!---->
+
+ <!---->
+
<span
+ class="award-emoji-block"
data-testid="award-html"
>
@@ -167,9 +232,14 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</span>
<span
- class="award-control-text js-counter"
+ class="gl-button-text"
>
- 1
+
+ <span
+ class="js-counter"
+ >
+ 1
+ </span>
</span>
</button>
@@ -178,46 +248,59 @@ 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"
+ class="btn add-reaction-button js-add-award btn-default btn-md gl-button js-test-add-button-class"
title="Add reaction"
type="button"
>
- <span
- class="award-control-icon award-control-icon-neutral"
- >
- <gl-icon-stub
- aria-hidden="true"
- name="slight-smile"
- size="16"
- />
- </span>
+ <!---->
+ <!---->
+
<span
- class="award-control-icon award-control-icon-positive"
+ class="gl-button-text"
>
- <gl-icon-stub
- aria-hidden="true"
- name="smiley"
- size="16"
- />
+ <span
+ class="reaction-control-icon reaction-control-icon-neutral"
+ >
+ <svg
+ aria-hidden="true"
+ class="gl-icon s16"
+ data-testid="slight-smile-icon"
+ >
+ <use
+ href="#slight-smile"
+ />
+ </svg>
+ </span>
+
+ <span
+ class="reaction-control-icon reaction-control-icon-positive"
+ >
+ <svg
+ aria-hidden="true"
+ class="gl-icon s16"
+ data-testid="smiley-icon"
+ >
+ <use
+ href="#smiley"
+ />
+ </svg>
+ </span>
+
+ <span
+ class="reaction-control-icon reaction-control-icon-super-positive"
+ >
+ <svg
+ aria-hidden="true"
+ class="gl-icon s16"
+ data-testid="smile-icon"
+ >
+ <use
+ href="#smile"
+ />
+ </svg>
+ </span>
</span>
-
- <span
- class="award-control-icon award-control-icon-super-positive"
- >
- <gl-icon-stub
- aria-hidden="true"
- name="smiley"
- size="16"
- />
- </span>
-
- <gl-loading-icon-stub
- class="award-control-icon-loading"
- color="dark"
- label="Loading"
- size="md"
- />
</button>
</div>
</div>
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 ec4a81054db..63d38e7587a 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
@@ -4,7 +4,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
<gl-dropdown-stub
category="primary"
headertext=""
- right=""
+ right="true"
size="medium"
text="Clone"
variant="info"
diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap
index 19a649089e0..adb6c935f96 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap
@@ -11,6 +11,7 @@ exports[`Expand button on click when short text is provided renders button after
<!---->
<svg
+ aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="ellipsis_h-icon"
>
@@ -39,6 +40,7 @@ exports[`Expand button on click when short text is provided renders button after
<!---->
<svg
+ aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="ellipsis_h-icon"
>
@@ -62,6 +64,7 @@ exports[`Expand button when short text is provided renders button before text 1`
<!---->
<svg
+ aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="ellipsis_h-icon"
>
@@ -90,6 +93,7 @@ exports[`Expand button when short text is provided renders button before text 1`
<!---->
<svg
+ aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="ellipsis_h-icon"
>
diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
index 8eb0e8f9550..dd88ba9a6fb 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
@@ -2,7 +2,7 @@
exports[`SplitButton renders actionItems 1`] = `
<gl-dropdown-stub
- category="tertiary"
+ category="primary"
headertext=""
menu-class=""
size="medium"
@@ -14,6 +14,7 @@ exports[`SplitButton renders actionItems 1`] = `
avatarurl=""
iconcolor=""
iconname=""
+ iconrightarialabel=""
iconrightname=""
ischecked="true"
ischeckitem="true"
@@ -33,6 +34,7 @@ exports[`SplitButton renders actionItems 1`] = `
avatarurl=""
iconcolor=""
iconname=""
+ iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js
index 63fc8a5749d..d20de81c446 100644
--- a/spec/frontend/vue_shared/components/awards_list_spec.js
+++ b/spec/frontend/vue_shared/components/awards_list_spec.js
@@ -1,4 +1,4 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import AwardsList from '~/vue_shared/components/awards_list.vue';
const createUser = (id, name) => ({ id, name });
@@ -41,6 +41,8 @@ const TEST_AWARDS = [
];
const TEST_ADD_BUTTON_CLASS = 'js-test-add-button-class';
+const REACTION_CONTROL_CLASSES = ['btn', 'gl-mr-3', 'btn-default', 'btn-md', 'gl-button'];
+
describe('vue_shared/components/awards_list', () => {
let wrapper;
@@ -54,16 +56,16 @@ describe('vue_shared/components/awards_list', () => {
throw new Error('There should only be one wrapper created per test');
}
- wrapper = shallowMount(AwardsList, { propsData: props });
+ wrapper = mount(AwardsList, { propsData: props });
};
const matchingEmojiTag = name => expect.stringMatching(`gl-emoji data-name="${name}"`);
- const findAwardButtons = () => wrapper.findAll('[data-testid="award-button"');
+ const findAwardButtons = () => wrapper.findAll('[data-testid="award-button"]');
const findAwardsData = () =>
findAwardButtons().wrappers.map(x => {
return {
classes: x.classes(),
title: x.attributes('title'),
- html: x.find('[data-testid="award-html"]').element.innerHTML,
+ html: x.find('[data-testid="award-html"]').html(),
count: Number(x.find('.js-counter').text()),
};
});
@@ -86,43 +88,43 @@ describe('vue_shared/components/awards_list', () => {
it('shows awards in correct order', () => {
expect(findAwardsData()).toEqual([
{
- classes: ['btn', 'award-control'],
+ classes: REACTION_CONTROL_CLASSES,
count: 3,
html: matchingEmojiTag(EMOJI_THUMBSUP),
title: 'Ada, Leonardo, and Marie',
},
{
- classes: ['btn', 'award-control', 'active'],
+ classes: [...REACTION_CONTROL_CLASSES, 'selected'],
count: 3,
html: matchingEmojiTag(EMOJI_THUMBSDOWN),
title: 'You, Ada, and Marie',
},
{
- classes: ['btn', 'award-control'],
+ classes: REACTION_CONTROL_CLASSES,
count: 2,
html: matchingEmojiTag(EMOJI_SMILE),
title: 'Ada and Jane',
},
{
- classes: ['btn', 'award-control', 'active'],
+ classes: [...REACTION_CONTROL_CLASSES, 'selected'],
count: 4,
html: matchingEmojiTag(EMOJI_OK),
title: 'You, Ada, Jane, and Leonardo',
},
{
- classes: ['btn', 'award-control', 'active'],
+ classes: [...REACTION_CONTROL_CLASSES, 'selected'],
count: 1,
html: matchingEmojiTag(EMOJI_CACTUS),
title: 'You',
},
{
- classes: ['btn', 'award-control'],
+ classes: REACTION_CONTROL_CLASSES,
count: 1,
html: matchingEmojiTag(EMOJI_A),
title: 'Marie',
},
{
- classes: ['btn', 'award-control', 'active'],
+ classes: [...REACTION_CONTROL_CLASSES, 'selected'],
count: 1,
html: matchingEmojiTag(EMOJI_B),
title: 'You',
@@ -135,7 +137,7 @@ describe('vue_shared/components/awards_list', () => {
findAwardButtons()
.at(2)
- .trigger('click');
+ .vm.$emit('click');
expect(wrapper.emitted().award).toEqual([[EMOJI_SMILE]]);
});
@@ -162,7 +164,7 @@ describe('vue_shared/components/awards_list', () => {
findAwardButtons()
.at(0)
- .trigger('click');
+ .vm.$emit('click');
expect(wrapper.emitted().award).toEqual([[Number(EMOJI_100)]]);
});
@@ -225,26 +227,26 @@ describe('vue_shared/components/awards_list', () => {
it('shows awards in correct order', () => {
expect(findAwardsData()).toEqual([
{
- classes: ['btn', 'award-control'],
+ classes: REACTION_CONTROL_CLASSES,
count: 0,
html: matchingEmojiTag(EMOJI_THUMBSUP),
title: '',
},
{
- classes: ['btn', 'award-control'],
+ classes: REACTION_CONTROL_CLASSES,
count: 0,
html: matchingEmojiTag(EMOJI_THUMBSDOWN),
title: '',
},
// We expect the EMOJI_100 before the EMOJI_SMILE because it was given as a defaultAward
{
- classes: ['btn', 'award-control'],
+ classes: REACTION_CONTROL_CLASSES,
count: 1,
html: matchingEmojiTag(EMOJI_100),
title: 'Marie',
},
{
- classes: ['btn', 'award-control'],
+ classes: REACTION_CONTROL_CLASSES,
count: 1,
html: matchingEmojiTag(EMOJI_SMILE),
title: 'Marie',
diff --git a/spec/frontend/vue_shared/components/callout_spec.js b/spec/frontend/vue_shared/components/callout_spec.js
deleted file mode 100644
index 7c9bb6b4650..00000000000
--- a/spec/frontend/vue_shared/components/callout_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Callout from '~/vue_shared/components/callout.vue';
-
-const TEST_MESSAGE = 'This is a callout message!';
-const TEST_SLOT = '<button>This is a callout slot!</button>';
-
-describe('Callout Component', () => {
- let wrapper;
-
- const factory = options => {
- wrapper = shallowMount(Callout, {
- ...options,
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('should render the appropriate variant of callout', () => {
- factory({
- propsData: {
- category: 'info',
- message: TEST_MESSAGE,
- },
- });
-
- expect(wrapper.classes()).toEqual(['bs-callout', 'bs-callout-info']);
-
- expect(wrapper.element.tagName).toEqual('DIV');
- });
-
- it('should render accessibility attributes', () => {
- factory({
- propsData: {
- message: TEST_MESSAGE,
- },
- });
-
- expect(wrapper.attributes('role')).toEqual('alert');
- expect(wrapper.attributes('aria-live')).toEqual('assertive');
- });
-
- it('should render the provided message', () => {
- factory({
- propsData: {
- message: TEST_MESSAGE,
- },
- });
-
- expect(wrapper.element.innerHTML.trim()).toEqual(TEST_MESSAGE);
- });
-
- it('should render the provided slot', () => {
- factory({
- slots: {
- default: TEST_SLOT,
- },
- });
-
- expect(wrapper.element.innerHTML.trim()).toEqual(TEST_SLOT);
- });
-});
diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js
index 51a2653befc..ac0be1537b7 100644
--- a/spec/frontend/vue_shared/components/clipboard_button_spec.js
+++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js
@@ -1,16 +1,19 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
describe('clipboard button', () => {
let wrapper;
- const createWrapper = propsData => {
- wrapper = shallowMount(ClipboardButton, {
+ const createWrapper = (propsData, options = {}) => {
+ wrapper = mount(ClipboardButton, {
propsData,
+ ...options,
});
};
+ const findButton = () => wrapper.find(GlButton);
+
afterEach(() => {
wrapper.destroy();
wrapper = null;
@@ -26,7 +29,7 @@ describe('clipboard button', () => {
});
it('renders a button for clipboard', () => {
- expect(wrapper.find(GlButton).exists()).toBe(true);
+ expect(findButton().exists()).toBe(true);
expect(wrapper.attributes('data-clipboard-text')).toBe('copy me');
});
@@ -53,4 +56,35 @@ describe('clipboard button', () => {
);
});
});
+
+ it('renders default slot as button text', () => {
+ createWrapper(
+ {
+ text: 'copy me',
+ title: 'Copy this value',
+ },
+ {
+ slots: {
+ default: 'Foo bar',
+ },
+ },
+ );
+
+ expect(findButton().text()).toBe('Foo bar');
+ });
+
+ it('re-emits button events', () => {
+ const onClick = jest.fn();
+ createWrapper(
+ {
+ text: 'copy me',
+ title: 'Copy this value',
+ },
+ { listeners: { click: onClick } },
+ );
+
+ findButton().trigger('click');
+
+ expect(onClick).toHaveBeenCalled();
+ });
});
diff --git a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js
new file mode 100644
index 00000000000..a50a4b742b3
--- /dev/null
+++ b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js
@@ -0,0 +1,140 @@
+import { GlFormGroup, GlFormInput, GlFormInputGroup, GlLink } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+
+import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue';
+
+describe('ColorPicker', () => {
+ let wrapper;
+
+ const createComponent = (fn = mount, propsData = {}) => {
+ wrapper = fn(ColorPicker, {
+ propsData,
+ });
+ };
+
+ const setColor = '#000000';
+ const label = () => wrapper.find(GlFormGroup).attributes('label');
+ const colorPreview = () => wrapper.find('[data-testid="color-preview"]');
+ const colorPicker = () => wrapper.find(GlFormInput);
+ const colorInput = () => wrapper.find(GlFormInputGroup).find('input[type="text"]');
+ const invalidFeedback = () => wrapper.find('.invalid-feedback');
+ const description = () => wrapper.find(GlFormGroup).attributes('description');
+ const presetColors = () => wrapper.findAll(GlLink);
+
+ beforeEach(() => {
+ gon.suggested_label_colors = {
+ [setColor]: 'Black',
+ '#0033CC': 'UA blue',
+ '#428BCA': 'Moderate blue',
+ '#44AD8E': 'Lime green',
+ };
+
+ createComponent(shallowMount);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('label', () => {
+ it('hides the label if the label is not passed', () => {
+ expect(label()).toBe('');
+ });
+
+ it('shows the label if the label is passed', () => {
+ createComponent(shallowMount, { label: 'test' });
+
+ expect(label()).toBe('test');
+ });
+ });
+
+ describe('behavior', () => {
+ it('by default has no values', () => {
+ createComponent();
+
+ expect(colorPreview().attributes('style')).toBe(undefined);
+ expect(colorPicker().attributes('value')).toBe(undefined);
+ expect(colorInput().props('value')).toBe('');
+ });
+
+ it('has a color set on initialization', () => {
+ createComponent(shallowMount, { setColor });
+
+ expect(wrapper.vm.$data.selectedColor).toBe(setColor);
+ });
+
+ it('emits input event from component when a color is selected', async () => {
+ createComponent();
+ await colorInput().setValue(setColor);
+
+ expect(wrapper.emitted().input[0]).toEqual([setColor]);
+ });
+
+ it('trims spaces from submitted colors', async () => {
+ createComponent();
+ await colorInput().setValue(` ${setColor} `);
+
+ expect(wrapper.vm.$data.selectedColor).toBe(setColor);
+ });
+
+ it('shows invalid feedback when an invalid color is used', async () => {
+ createComponent();
+ await colorInput().setValue('abcd');
+
+ expect(invalidFeedback().text()).toBe(
+ 'Please enter a valid hex (#RRGGBB or #RGB) color value',
+ );
+ expect(wrapper.emitted().input).toBe(undefined);
+ });
+
+ it('shows an invalid feedback border on the preview when an invalid color is used', async () => {
+ createComponent();
+ await colorInput().setValue('abcd');
+
+ expect(colorPreview().attributes('class')).toContain('gl-inset-border-1-red-500');
+ });
+ });
+
+ describe('inputs', () => {
+ it('has color input value entered', async () => {
+ createComponent();
+ await colorInput().setValue(setColor);
+
+ expect(wrapper.vm.$data.selectedColor).toBe(setColor);
+ });
+
+ it('has color picker value entered', async () => {
+ createComponent();
+ await colorPicker().setValue(setColor);
+
+ expect(wrapper.vm.$data.selectedColor).toBe(setColor);
+ });
+ });
+
+ describe('preset colors', () => {
+ it('hides the suggested colors if they are empty', () => {
+ gon.suggested_label_colors = {};
+ createComponent(shallowMount);
+
+ expect(description()).toBe('Choose any color');
+ expect(presetColors().exists()).toBe(false);
+ });
+
+ it('shows the suggested colors', () => {
+ createComponent(shallowMount);
+ expect(description()).toBe(
+ 'Choose any color. Or you can choose one of the suggested colors below',
+ );
+ expect(presetColors()).toHaveLength(4);
+ });
+
+ it('has preset color selected', async () => {
+ createComponent();
+ await presetColors()
+ .at(0)
+ .trigger('click');
+
+ expect(wrapper.vm.$data.selectedColor).toBe(setColor);
+ });
+ });
+});
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 64bfff3dfa1..8cc5d6775a7 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
@@ -17,11 +17,14 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se
import {
mockAvailableTokens,
+ mockMembershipToken,
+ mockMembershipTokenOptionsWithoutTitles,
mockSortOptions,
mockHistoryItems,
tokenValueAuthor,
tokenValueLabel,
tokenValueMilestone,
+ tokenValueMembership,
} from './mock_data';
jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({
@@ -412,6 +415,42 @@ describe('FilteredSearchBarRoot', () => {
wrapperFullMount.destroy();
});
+ describe('when token options have `title` attribute defined', () => {
+ it('renders search history items using the provided `title` attribute', async () => {
+ const wrapperFullMount = createComponent({
+ sortOptions: mockSortOptions,
+ tokens: [mockMembershipToken],
+ shallow: false,
+ });
+
+ wrapperFullMount.vm.recentSearchesStore.addRecentSearch([tokenValueMembership]);
+
+ await wrapperFullMount.vm.$nextTick();
+
+ expect(wrapperFullMount.find(GlDropdownItem).text()).toBe('Membership := Direct');
+
+ wrapperFullMount.destroy();
+ });
+ });
+
+ describe('when token options have do not have `title` attribute defined', () => {
+ it('renders search history items using the provided `value` attribute', async () => {
+ const wrapperFullMount = createComponent({
+ sortOptions: mockSortOptions,
+ tokens: [mockMembershipTokenOptionsWithoutTitles],
+ shallow: false,
+ });
+
+ wrapperFullMount.vm.recentSearchesStore.addRecentSearch([tokenValueMembership]);
+
+ await wrapperFullMount.vm.$nextTick();
+
+ expect(wrapperFullMount.find(GlDropdownItem).text()).toBe('Membership := exclude');
+
+ wrapperFullMount.destroy();
+ });
+ });
+
it('renders sort dropdown component', () => {
expect(wrapper.find(GlButtonGroup).exists()).toBe(true);
expect(wrapper.find(GlDropdown).exists()).toBe(true);
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 e0a3208cac9..64fbe70696d 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
@@ -1,3 +1,4 @@
+import { GlFilteredSearchToken } from '@gitlab/ui';
import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
@@ -102,6 +103,21 @@ export const mockMilestoneToken = {
fetchMilestones: () => Promise.resolve({ data: mockMilestones }),
};
+export const mockMembershipToken = {
+ type: 'with_inherited_permissions',
+ icon: 'group',
+ title: 'Membership',
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: [{ value: '=', description: 'is' }],
+ options: [{ value: 'exclude', title: 'Direct' }, { value: 'only', title: 'Inherited' }],
+};
+
+export const mockMembershipTokenOptionsWithoutTitles = {
+ ...mockMembershipToken,
+ options: [{ value: 'exclude' }, { value: 'only' }],
+};
+
export const mockAvailableTokens = [mockAuthorToken, mockLabelToken, mockMilestoneToken];
export const tokenValueAuthor = {
@@ -128,6 +144,14 @@ export const tokenValueMilestone = {
},
};
+export const tokenValueMembership = {
+ type: 'with_inherited_permissions',
+ value: {
+ operator: '=',
+ data: 'exclude',
+ },
+};
+
export const tokenValuePlain = {
type: 'filtered-search-term',
value: { data: 'foo' },
diff --git a/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap b/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap
new file mode 100644
index 00000000000..d0fa2086fdc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap
@@ -0,0 +1,47 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`gfm_autocomplete/utils issues config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context issue title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
+
+exports[`gfm_autocomplete/utils issues config shows the reference and title in the menu item within a group context 1`] = `"<small>gitlab#987654</small> Group context issue title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
+
+exports[`gfm_autocomplete/utils labels config shows the title in the menu item 1`] = `
+"
+ <span class=\\"dropdown-label-box\\" style=\\"background: #123456;\\"></span>
+ bug &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"
+`;
+
+exports[`gfm_autocomplete/utils members config shows an avatar character, name, parent name, and count in the menu item for a group 1`] = `
+"
+ <div class=\\"gl-display-flex gl-align-items-center\\">
+ <div class=\\"gl-avatar gl-avatar-s24 gl-flex-shrink-0 gl-rounded-small
+ gl-display-flex gl-align-items-center gl-justify-content-center\\" aria-hidden=\\"true\\">
+ G</div>
+ <div class=\\"gl-font-sm gl-line-height-normal gl-ml-3\\">
+ <div>1-1s &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt; (2)</div>
+ <div class=\\"gl-text-gray-700\\">GitLab Support Team</div>
+ </div>
+
+ </div>
+ "
+`;
+
+exports[`gfm_autocomplete/utils members config shows the avatar, name and username in the menu item for a user 1`] = `
+"
+ <div class=\\"gl-display-flex gl-align-items-center\\">
+ <img class=\\"gl-avatar gl-avatar-s24 gl-flex-shrink-0 gl-avatar-circle\\" src=\\"/uploads/-/system/user/avatar/123456/avatar.png\\" alt=\\"\\" />
+ <div class=\\"gl-font-sm gl-line-height-normal gl-ml-3\\">
+ <div>My Name &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;</div>
+ <div class=\\"gl-text-gray-700\\">@myusername</div>
+ </div>
+
+ </div>
+ "
+`;
+
+exports[`gfm_autocomplete/utils merge requests config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context merge request title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
+
+exports[`gfm_autocomplete/utils merge requests config shows the reference and title in the menu item within a group context 1`] = `"<small>gitlab!456789</small> Group context merge request title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
+
+exports[`gfm_autocomplete/utils milestones config shows the title in the menu item 1`] = `"13.2 &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
+
+exports[`gfm_autocomplete/utils snippets config shows the id and title in the menu item 1`] = `"<small>123456</small> Snippet title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
diff --git a/spec/frontend/vue_shared/components/gl_mentions_spec.js b/spec/frontend/vue_shared/components/gfm_autocomplete/gfm_autocomplete_spec.js
index 32fc055a77d..b4002fdf4ec 100644
--- a/spec/frontend/vue_shared/components/gl_mentions_spec.js
+++ b/spec/frontend/vue_shared/components/gfm_autocomplete/gfm_autocomplete_spec.js
@@ -1,15 +1,15 @@
+import Tribute from '@gitlab/tributejs';
import { shallowMount } from '@vue/test-utils';
-import Tribute from 'tributejs';
-import GlMentions from '~/vue_shared/components/gl_mentions.vue';
+import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue';
-describe('GlMentions', () => {
+describe('GfmAutocomplete', () => {
let wrapper;
- describe('Tribute', () => {
+ describe('tribute', () => {
const mentions = '/gitlab-org/gitlab-test/-/autocomplete_sources/members?type=Issue&type_id=1';
beforeEach(() => {
- wrapper = shallowMount(GlMentions, {
+ wrapper = shallowMount(GfmAutocomplete, {
propsData: {
dataSources: {
mentions,
diff --git a/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js
new file mode 100644
index 00000000000..647f8c6e000
--- /dev/null
+++ b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js
@@ -0,0 +1,344 @@
+import { escape, last } from 'lodash';
+import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils';
+
+describe('gfm_autocomplete/utils', () => {
+ describe('issues config', () => {
+ const issuesConfig = tributeConfig[GfmAutocompleteType.Issues].config;
+ const groupContextIssue = {
+ iid: 987654,
+ reference: 'gitlab#987654',
+ title: "Group context issue title <script>alert('hi')</script>",
+ };
+ const projectContextIssue = {
+ id: null,
+ iid: 123456,
+ time_estimate: 0,
+ title: "Project context issue title <script>alert('hi')</script>",
+ };
+
+ it('uses # as the trigger', () => {
+ expect(issuesConfig.trigger).toBe('#');
+ });
+
+ it('searches using both the iid and title', () => {
+ expect(issuesConfig.lookup(projectContextIssue)).toBe(
+ `${projectContextIssue.iid}${projectContextIssue.title}`,
+ );
+ });
+
+ it('shows the reference and title in the menu item within a group context', () => {
+ expect(issuesConfig.menuItemTemplate({ original: groupContextIssue })).toMatchSnapshot();
+ });
+
+ it('shows the iid and title in the menu item within a project context', () => {
+ expect(issuesConfig.menuItemTemplate({ original: projectContextIssue })).toMatchSnapshot();
+ });
+
+ it('inserts the reference on autocomplete selection within a group context', () => {
+ expect(issuesConfig.selectTemplate({ original: groupContextIssue })).toBe(
+ groupContextIssue.reference,
+ );
+ });
+
+ it('inserts the iid on autocomplete selection within a project context', () => {
+ expect(issuesConfig.selectTemplate({ original: projectContextIssue })).toBe(
+ `#${projectContextIssue.iid}`,
+ );
+ });
+ });
+
+ describe('labels config', () => {
+ const labelsConfig = tributeConfig[GfmAutocompleteType.Labels].config;
+ const labelsFilter = tributeConfig[GfmAutocompleteType.Labels].filterValues;
+ const label = {
+ color: '#123456',
+ textColor: '#FFFFFF',
+ title: `bug <script>alert('hi')</script>`,
+ type: 'GroupLabel',
+ };
+ const singleWordLabel = {
+ color: '#456789',
+ textColor: '#DDD',
+ title: `bug`,
+ type: 'GroupLabel',
+ };
+ const numericalLabel = {
+ color: '#abcdef',
+ textColor: '#AAA',
+ title: 123456,
+ type: 'ProjectLabel',
+ };
+
+ it('uses ~ as the trigger', () => {
+ expect(labelsConfig.trigger).toBe('~');
+ });
+
+ it('searches using `title`', () => {
+ expect(labelsConfig.lookup).toBe('title');
+ });
+
+ it('shows the title in the menu item', () => {
+ expect(labelsConfig.menuItemTemplate({ original: label })).toMatchSnapshot();
+ });
+
+ it('inserts the title on autocomplete selection', () => {
+ expect(labelsConfig.selectTemplate({ original: singleWordLabel })).toBe(
+ `~${escape(singleWordLabel.title)}`,
+ );
+ });
+
+ it('inserts the title enclosed with quotes on autocomplete selection when the title is numerical', () => {
+ expect(labelsConfig.selectTemplate({ original: numericalLabel })).toBe(
+ `~"${escape(numericalLabel.title)}"`,
+ );
+ });
+
+ it('inserts the title enclosed with quotes on autocomplete selection when the title contains multiple words', () => {
+ expect(labelsConfig.selectTemplate({ original: label })).toBe(`~"${escape(label.title)}"`);
+ });
+
+ describe('filter', () => {
+ const collection = [label, singleWordLabel, { ...numericalLabel, set: true }];
+
+ describe('/label quick action', () => {
+ describe('when the line starts with `/label`', () => {
+ it('shows labels that are not currently selected', () => {
+ const fullText = '/label ~';
+ const selectionStart = 8;
+
+ expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([
+ collection[0],
+ collection[1],
+ ]);
+ });
+ });
+
+ describe('when the line does not start with `/label`', () => {
+ it('shows all labels', () => {
+ const fullText = '~';
+ const selectionStart = 1;
+
+ expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection);
+ });
+ });
+ });
+
+ describe('/unlabel quick action', () => {
+ describe('when the line starts with `/unlabel`', () => {
+ it('shows labels that are currently selected', () => {
+ const fullText = '/unlabel ~';
+ const selectionStart = 10;
+
+ expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([collection[2]]);
+ });
+ });
+
+ describe('when the line does not start with `/unlabel`', () => {
+ it('shows all labels', () => {
+ const fullText = '~';
+ const selectionStart = 1;
+
+ expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection);
+ });
+ });
+ });
+ });
+ });
+
+ describe('members config', () => {
+ const membersConfig = tributeConfig[GfmAutocompleteType.Members].config;
+ const membersFilter = tributeConfig[GfmAutocompleteType.Members].filterValues;
+ const userMember = {
+ type: 'User',
+ username: 'myusername',
+ name: "My Name <script>alert('hi')</script>",
+ avatar_url: '/uploads/-/system/user/avatar/123456/avatar.png',
+ availability: null,
+ };
+ const groupMember = {
+ type: 'Group',
+ username: 'gitlab-com/support/1-1s',
+ name: "GitLab.com / GitLab Support Team / 1-1s <script>alert('hi')</script>",
+ avatar_url: null,
+ count: 2,
+ mentionsDisabled: null,
+ };
+
+ it('uses @ as the trigger', () => {
+ expect(membersConfig.trigger).toBe('@');
+ });
+
+ it('inserts the username on autocomplete selection', () => {
+ expect(membersConfig.fillAttr).toBe('username');
+ });
+
+ it('searches using both the name and username for a user', () => {
+ expect(membersConfig.lookup(userMember)).toBe(`${userMember.name}${userMember.username}`);
+ });
+
+ it('searches using only its own name and not its ancestors for a group', () => {
+ expect(membersConfig.lookup(groupMember)).toBe(last(groupMember.name.split(' / ')));
+ });
+
+ it('shows the avatar, name and username in the menu item for a user', () => {
+ expect(membersConfig.menuItemTemplate({ original: userMember })).toMatchSnapshot();
+ });
+
+ it('shows an avatar character, name, parent name, and count in the menu item for a group', () => {
+ expect(membersConfig.menuItemTemplate({ original: groupMember })).toMatchSnapshot();
+ });
+
+ describe('filter', () => {
+ const assignees = [userMember.username];
+ const collection = [userMember, groupMember];
+
+ describe('/assign quick action', () => {
+ describe('when the line starts with `/assign`', () => {
+ it('shows members that are not currently selected', () => {
+ const fullText = '/assign @';
+ const selectionStart = 9;
+
+ expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([
+ collection[1],
+ ]);
+ });
+ });
+
+ describe('when the line does not start with `/assign`', () => {
+ it('shows all labels', () => {
+ const fullText = '@';
+ const selectionStart = 1;
+
+ expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual(
+ collection,
+ );
+ });
+ });
+ });
+
+ describe('/unassign quick action', () => {
+ describe('when the line starts with `/unassign`', () => {
+ it('shows members that are currently selected', () => {
+ const fullText = '/unassign @';
+ const selectionStart = 11;
+
+ expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([
+ collection[0],
+ ]);
+ });
+ });
+
+ describe('when the line does not start with `/unassign`', () => {
+ it('shows all members', () => {
+ const fullText = '@';
+ const selectionStart = 1;
+
+ expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual(
+ collection,
+ );
+ });
+ });
+ });
+ });
+ });
+
+ describe('merge requests config', () => {
+ const mergeRequestsConfig = tributeConfig[GfmAutocompleteType.MergeRequests].config;
+ const groupContextMergeRequest = {
+ iid: 456789,
+ reference: 'gitlab!456789',
+ title: "Group context merge request title <script>alert('hi')</script>",
+ };
+ const projectContextMergeRequest = {
+ id: null,
+ iid: 123456,
+ time_estimate: 0,
+ title: "Project context merge request title <script>alert('hi')</script>",
+ };
+
+ it('uses ! as the trigger', () => {
+ expect(mergeRequestsConfig.trigger).toBe('!');
+ });
+
+ it('searches using both the iid and title', () => {
+ expect(mergeRequestsConfig.lookup(projectContextMergeRequest)).toBe(
+ `${projectContextMergeRequest.iid}${projectContextMergeRequest.title}`,
+ );
+ });
+
+ it('shows the reference and title in the menu item within a group context', () => {
+ expect(
+ mergeRequestsConfig.menuItemTemplate({ original: groupContextMergeRequest }),
+ ).toMatchSnapshot();
+ });
+
+ it('shows the iid and title in the menu item within a project context', () => {
+ expect(
+ mergeRequestsConfig.menuItemTemplate({ original: projectContextMergeRequest }),
+ ).toMatchSnapshot();
+ });
+
+ it('inserts the reference on autocomplete selection within a group context', () => {
+ expect(mergeRequestsConfig.selectTemplate({ original: groupContextMergeRequest })).toBe(
+ groupContextMergeRequest.reference,
+ );
+ });
+
+ it('inserts the iid on autocomplete selection within a project context', () => {
+ expect(mergeRequestsConfig.selectTemplate({ original: projectContextMergeRequest })).toBe(
+ `!${projectContextMergeRequest.iid}`,
+ );
+ });
+ });
+
+ describe('milestones config', () => {
+ const milestonesConfig = tributeConfig[GfmAutocompleteType.Milestones].config;
+ const milestone = {
+ id: null,
+ iid: 49,
+ title: "13.2 <script>alert('hi')</script>",
+ };
+
+ it('uses % as the trigger', () => {
+ expect(milestonesConfig.trigger).toBe('%');
+ });
+
+ it('searches using the title', () => {
+ expect(milestonesConfig.lookup).toBe('title');
+ });
+
+ it('shows the title in the menu item', () => {
+ expect(milestonesConfig.menuItemTemplate({ original: milestone })).toMatchSnapshot();
+ });
+
+ it('inserts the title on autocomplete selection', () => {
+ expect(milestonesConfig.selectTemplate({ original: milestone })).toBe(
+ `%"${escape(milestone.title)}"`,
+ );
+ });
+ });
+
+ describe('snippets config', () => {
+ const snippetsConfig = tributeConfig[GfmAutocompleteType.Snippets].config;
+ const snippet = {
+ id: 123456,
+ title: "Snippet title <script>alert('hi')</script>",
+ };
+
+ it('uses $ as the trigger', () => {
+ expect(snippetsConfig.trigger).toBe('$');
+ });
+
+ it('inserts the id on autocomplete selection', () => {
+ expect(snippetsConfig.fillAttr).toBe('id');
+ });
+
+ it('searches using both the id and title', () => {
+ expect(snippetsConfig.lookup(snippet)).toBe(`${snippet.id}${snippet.title}`);
+ });
+
+ it('shows the id and title in the menu item', () => {
+ expect(snippetsConfig.menuItemTemplate({ original: snippet })).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
index c87d19df1f7..d1bfc180082 100644
--- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
+++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
@@ -33,28 +33,31 @@ describe('IssueMilestoneComponent', () => {
describe('computed', () => {
describe('isMilestoneStarted', () => {
- it('should return `false` when milestoneStart prop is not defined', () => {
+ it('should return `false` when milestoneStart prop is not defined', async () => {
wrapper.setProps({
milestone: { ...mockMilestone, start_date: '' },
});
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.isMilestoneStarted).toBe(false);
});
- it('should return `true` when milestone start date is past current date', () => {
- wrapper.setProps({
+ it('should return `true` when milestone start date is past current date', async () => {
+ await wrapper.setProps({
milestone: { ...mockMilestone, start_date: '1990-07-22' },
});
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.isMilestoneStarted).toBe(true);
});
});
describe('isMilestonePastDue', () => {
- it('should return `false` when milestoneDue prop is not defined', () => {
+ it('should return `false` when milestoneDue prop is not defined', async () => {
wrapper.setProps({
milestone: { ...mockMilestone, due_date: '' },
});
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.isMilestonePastDue).toBe(false);
});
@@ -73,41 +76,45 @@ describe('IssueMilestoneComponent', () => {
expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)');
});
- it('returns string containing absolute milestone start date when due date is not present', () => {
+ it('returns string containing absolute milestone start date when due date is not present', async () => {
wrapper.setProps({
milestone: { ...mockMilestone, due_date: '' },
});
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)');
});
- it('returns empty string when both milestone start and due dates are not present', () => {
+ it('returns empty string when both milestone start and due dates are not present', async () => {
wrapper.setProps({
milestone: { ...mockMilestone, start_date: '', due_date: '' },
});
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.milestoneDatesAbsolute).toBe('');
});
});
describe('milestoneDatesHuman', () => {
- it('returns string containing milestone due date when date is yet to be due', () => {
+ it('returns string containing milestone due date when date is yet to be due', async () => {
wrapper.setProps({
milestone: { ...mockMilestone, due_date: `${new Date().getFullYear() + 10}-01-01` },
});
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining');
});
- it('returns string containing milestone start date when date has already started and due date is not present', () => {
+ it('returns string containing milestone start date when date has already started and due date is not present', async () => {
wrapper.setProps({
milestone: { ...mockMilestone, start_date: '1990-07-22', due_date: '' },
});
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.milestoneDatesHuman).toContain('Started');
});
- it('returns string containing milestone start date when date is yet to start and due date is not present', () => {
+ it('returns string containing milestone start date when date is yet to start and due date is not present', async () => {
wrapper.setProps({
milestone: {
...mockMilestone,
@@ -115,14 +122,16 @@ describe('IssueMilestoneComponent', () => {
due_date: '',
},
});
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.milestoneDatesHuman).toContain('Starts');
});
- it('returns empty string when milestone start and due dates are not present', () => {
+ it('returns empty string when milestone start and due dates are not present', async () => {
wrapper.setProps({
milestone: { ...mockMilestone, start_date: '', due_date: '' },
});
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.milestoneDatesHuman).toBe('');
});
diff --git a/spec/frontend/vue_shared/components/loading_button_spec.js b/spec/frontend/vue_shared/components/loading_button_spec.js
deleted file mode 100644
index 8bcb80d140e..00000000000
--- a/spec/frontend/vue_shared/components/loading_button_spec.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
-
-const LABEL = 'Hello';
-
-describe('LoadingButton', () => {
- let wrapper;
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const buildWrapper = (propsData = {}) => {
- wrapper = shallowMount(LoadingButton, {
- propsData,
- });
- };
- const findButtonLabel = () => wrapper.find('.js-loading-button-label');
- const findButtonIcon = () => wrapper.find('.js-loading-button-icon');
-
- describe('loading spinner', () => {
- it('shown when loading', () => {
- buildWrapper({ loading: true });
-
- expect(findButtonIcon().exists()).toBe(true);
- });
- });
-
- describe('disabled state', () => {
- it('disabled when loading', () => {
- buildWrapper({ loading: true });
- expect(wrapper.attributes('disabled')).toBe('disabled');
- });
-
- it('not disabled when normal', () => {
- buildWrapper({ loading: false });
-
- expect(wrapper.attributes('disabled')).toBe(undefined);
- });
- });
-
- describe('label', () => {
- it('shown when normal', () => {
- buildWrapper({ loading: false, label: LABEL });
- expect(findButtonLabel().text()).toBe(LABEL);
- });
-
- it('shown when loading', () => {
- buildWrapper({ loading: false, label: LABEL });
- expect(findButtonLabel().text()).toBe(LABEL);
- });
- });
-
- describe('container class', () => {
- it('should default to btn btn-align-content', () => {
- buildWrapper();
-
- expect(wrapper.classes()).toContain('btn');
- expect(wrapper.classes()).toContain('btn-align-content');
- });
-
- it('should be configurable through props', () => {
- const containerClass = 'test-class';
-
- buildWrapper({
- containerClass,
- });
-
- expect(wrapper.classes()).not.toContain('btn');
- expect(wrapper.classes()).not.toContain('btn-align-content');
- expect(wrapper.classes()).toContain(containerClass);
- });
- });
-
- describe('click callback prop', () => {
- it('calls given callback when normal', () => {
- buildWrapper({
- loading: false,
- });
-
- wrapper.trigger('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('click')).toBeTruthy();
- });
- });
-
- it('does not call given callback when disabled because of loading', () => {
- buildWrapper({
- loading: true,
- });
-
- wrapper.trigger('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.emitted('click')).toBeFalsy();
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js
new file mode 100644
index 00000000000..0598506891b
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js
@@ -0,0 +1,72 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDropdown, GlFormTextarea, GlButton } from '@gitlab/ui';
+import ApplySuggestionComponent from '~/vue_shared/components/markdown/apply_suggestion.vue';
+
+describe('Apply Suggestion component', () => {
+ const propsData = { fileName: 'test.js', disabled: false };
+ let wrapper;
+
+ const createWrapper = props => {
+ wrapper = shallowMount(ApplySuggestionComponent, { propsData: { ...propsData, ...props } });
+ };
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findTextArea = () => wrapper.find(GlFormTextarea);
+ const findApplyButton = () => wrapper.find(GlButton);
+
+ beforeEach(() => createWrapper());
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('initial template', () => {
+ it('renders a dropdown with the correct props', () => {
+ const dropdown = findDropdown();
+
+ expect(dropdown.exists()).toBe(true);
+ expect(dropdown.props('text')).toBe('Apply suggestion');
+ expect(dropdown.props('headerText')).toBe('Apply suggestion commit message');
+ expect(dropdown.props('disabled')).toBe(false);
+ });
+
+ it('renders a textarea with the correct props', () => {
+ const textArea = findTextArea();
+
+ expect(textArea.exists()).toBe(true);
+ expect(textArea.attributes('placeholder')).toBe('Apply suggestion on test.js');
+ });
+
+ it('renders an apply button', () => {
+ const applyButton = findApplyButton();
+
+ expect(applyButton.exists()).toBe(true);
+ expect(applyButton.text()).toBe('Apply');
+ });
+ });
+
+ describe('disabled', () => {
+ it('disables the dropdown', () => {
+ createWrapper({ disabled: true });
+
+ expect(findDropdown().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('apply suggestion', () => {
+ it('emits an apply event with a default message if no message was added', () => {
+ findTextArea().vm.$emit('input', null);
+ findApplyButton().vm.$emit('click');
+
+ expect(wrapper.emitted('apply')).toEqual([['Apply suggestion on test.js']]);
+ });
+
+ it('emits an apply event with a user-defined message', () => {
+ findTextArea().vm.$emit('input', 'some text');
+ findApplyButton().vm.$emit('click');
+
+ expect(wrapper.emitted('apply')).toEqual([['some text']]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js
deleted file mode 100644
index 58cb8ef61d1..00000000000
--- a/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue';
-import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
-import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue';
-import { accessRequest as member } from '../mock_data';
-
-describe('AccessRequestActionButtons', () => {
- let wrapper;
-
- const createComponent = (propsData = {}) => {
- wrapper = shallowMount(AccessRequestActionButtons, {
- propsData: {
- member,
- isCurrentUser: true,
- ...propsData,
- },
- });
- };
-
- const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
- const findApproveButton = () => wrapper.find(ApproveAccessRequestButton);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when user has `canRemove` permissions', () => {
- beforeEach(() => {
- createComponent({
- permissions: {
- canRemove: true,
- },
- });
- });
-
- it('renders remove member button', () => {
- expect(findRemoveMemberButton().exists()).toBe(true);
- });
-
- it('sets props correctly', () => {
- expect(findRemoveMemberButton().props()).toMatchObject({
- memberId: member.id,
- title: 'Deny access',
- isAccessRequest: true,
- icon: 'close',
- });
- });
-
- describe('when member is the current user', () => {
- it('sets `message` prop correctly', () => {
- expect(findRemoveMemberButton().props('message')).toBe(
- `Are you sure you want to withdraw your access request for "${member.source.name}"`,
- );
- });
- });
-
- describe('when member is not the current user', () => {
- it('sets `message` prop correctly', () => {
- createComponent({
- isCurrentUser: false,
- permissions: {
- canRemove: true,
- },
- });
-
- expect(findRemoveMemberButton().props('message')).toBe(
- `Are you sure you want to deny ${member.user.name}'s request to join "${member.source.name}"`,
- );
- });
- });
- });
-
- describe('when user does not have `canRemove` permissions', () => {
- it('does not render remove member button', () => {
- createComponent({
- permissions: {
- canRemove: false,
- },
- });
-
- expect(findRemoveMemberButton().exists()).toBe(false);
- });
- });
-
- describe('when user has `canUpdate` permissions', () => {
- it('renders the approve button', () => {
- createComponent({
- permissions: {
- canUpdate: true,
- },
- });
-
- expect(findApproveButton().exists()).toBe(true);
- });
- });
-
- describe('when user does not have `canUpdate` permissions', () => {
- it('does not render the approve button', () => {
- createComponent({
- permissions: {
- canUpdate: false,
- },
- });
-
- expect(findApproveButton().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js
deleted file mode 100644
index 93edaaa400d..00000000000
--- a/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { GlButton, GlForm } from '@gitlab/ui';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue';
-
-jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('ApproveAccessRequestButton', () => {
- let wrapper;
-
- const createStore = (state = {}) => {
- return new Vuex.Store({
- state: {
- memberPath: '/groups/foo-bar/-/group_members/:id',
- ...state,
- },
- });
- };
-
- const createComponent = (propsData = {}, state) => {
- wrapper = shallowMount(ApproveAccessRequestButton, {
- localVue,
- store: createStore(state),
- propsData: {
- memberId: 1,
- ...propsData,
- },
- directives: {
- GlTooltip: createMockDirective(),
- },
- });
- };
-
- const findForm = () => wrapper.find(GlForm);
- const findButton = () => findForm().find(GlButton);
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('displays a tooltip', () => {
- const button = findButton();
-
- expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined();
- expect(button.attributes('title')).toBe('Grant access');
- });
-
- it('sets `aria-label` attribute', () => {
- expect(findButton().attributes('aria-label')).toBe('Grant access');
- });
-
- it('submits the form when button is clicked', () => {
- expect(findButton().attributes('type')).toBe('submit');
- });
-
- it('displays form with correct action and inputs', () => {
- const form = findForm();
-
- expect(form.attributes('action')).toBe(
- '/groups/foo-bar/-/group_members/1/approve_access_request',
- );
- expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
- 'mock-csrf-token',
- );
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js
deleted file mode 100644
index 1374cdc6aef..00000000000
--- a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue';
-import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
-import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue';
-import { invite as member } from '../mock_data';
-
-describe('InviteActionButtons', () => {
- let wrapper;
-
- const createComponent = (propsData = {}) => {
- wrapper = shallowMount(InviteActionButtons, {
- propsData: {
- member,
- ...propsData,
- },
- });
- };
-
- const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
- const findResendInviteButton = () => wrapper.find(ResendInviteButton);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when user has `canRemove` permissions', () => {
- beforeEach(() => {
- createComponent({
- permissions: {
- canRemove: true,
- },
- });
- });
-
- it('renders remove member button', () => {
- expect(findRemoveMemberButton().exists()).toBe(true);
- });
-
- it('sets props correctly', () => {
- expect(findRemoveMemberButton().props()).toEqual({
- memberId: member.id,
- message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.name}"`,
- title: 'Revoke invite',
- isAccessRequest: false,
- icon: 'remove',
- });
- });
- });
-
- describe('when user does not have `canRemove` permissions', () => {
- it('does not render remove member button', () => {
- createComponent({
- permissions: {
- canRemove: false,
- },
- });
-
- expect(findRemoveMemberButton().exists()).toBe(false);
- });
- });
-
- describe('when user has `canResend` permissions', () => {
- it('renders resend invite button', () => {
- createComponent({
- permissions: {
- canResend: true,
- },
- });
-
- expect(findResendInviteButton().exists()).toBe(true);
- });
- });
-
- describe('when user does not have `canResend` permissions', () => {
- it('does not render resend invite button', () => {
- createComponent({
- permissions: {
- canResend: false,
- },
- });
-
- expect(findResendInviteButton().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js
deleted file mode 100644
index 00896b23b95..00000000000
--- a/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlButton } from '@gitlab/ui';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue';
-import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue';
-import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants';
-import { member } from '../mock_data';
-
-describe('LeaveButton', () => {
- let wrapper;
-
- const createComponent = (propsData = {}) => {
- wrapper = shallowMount(LeaveButton, {
- propsData: {
- member,
- ...propsData,
- },
- directives: {
- GlTooltip: createMockDirective(),
- GlModal: createMockDirective(),
- },
- });
- };
-
- const findButton = () => wrapper.find(GlButton);
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('displays a tooltip', () => {
- const button = findButton();
-
- expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined();
- expect(button.attributes('title')).toBe('Leave');
- });
-
- it('sets `aria-label` attribute', () => {
- expect(findButton().attributes('aria-label')).toBe('Leave');
- });
-
- it('renders leave modal', () => {
- const leaveModal = wrapper.find(LeaveModal);
-
- expect(leaveModal.exists()).toBe(true);
- expect(leaveModal.props('member')).toEqual(member);
- });
-
- it('triggers leave modal', () => {
- const binding = getBinding(findButton().element, 'gl-modal');
-
- expect(binding).not.toBeUndefined();
- expect(binding.value).toBe(LEAVE_MODAL_ID);
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js
deleted file mode 100644
index 84fe1c51773..00000000000
--- a/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import { mount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { GlButton } from '@gitlab/ui';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import RemoveGroupLinkButton from '~/vue_shared/components/members/action_buttons/remove_group_link_button.vue';
-import { group } from '../mock_data';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('RemoveGroupLinkButton', () => {
- let wrapper;
-
- const actions = {
- showRemoveGroupLinkModal: jest.fn(),
- };
-
- const createStore = () => {
- return new Vuex.Store({
- actions,
- });
- };
-
- const createComponent = () => {
- wrapper = mount(RemoveGroupLinkButton, {
- localVue,
- store: createStore(),
- propsData: {
- groupLink: group,
- },
- directives: {
- GlTooltip: createMockDirective(),
- },
- });
- };
-
- const findButton = () => wrapper.find(GlButton);
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- it('displays a tooltip', () => {
- const button = findButton();
-
- expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined();
- expect(button.attributes('title')).toBe('Remove group');
- });
-
- it('sets `aria-label` attribute', () => {
- expect(findButton().attributes('aria-label')).toBe('Remove group');
- });
-
- it('calls Vuex action to open remove group link modal when clicked', () => {
- findButton().trigger('click');
-
- expect(actions.showRemoveGroupLinkModal).toHaveBeenCalledWith(expect.any(Object), group);
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js
deleted file mode 100644
index 7aa30494234..00000000000
--- a/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('RemoveMemberButton', () => {
- let wrapper;
-
- const createStore = (state = {}) => {
- return new Vuex.Store({
- state: {
- memberPath: '/groups/foo-bar/-/group_members/:id',
- ...state,
- },
- });
- };
-
- const createComponent = (propsData = {}, state) => {
- wrapper = shallowMount(RemoveMemberButton, {
- localVue,
- store: createStore(state),
- propsData: {
- memberId: 1,
- message: 'Are you sure you want to remove John Smith?',
- title: 'Remove member',
- isAccessRequest: true,
- ...propsData,
- },
- directives: {
- GlTooltip: createMockDirective(),
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('sets attributes on button', () => {
- createComponent();
-
- expect(wrapper.attributes()).toMatchObject({
- 'data-member-path': '/groups/foo-bar/-/group_members/1',
- 'data-message': 'Are you sure you want to remove John Smith?',
- 'data-is-access-request': 'true',
- 'aria-label': 'Remove member',
- title: 'Remove member',
- icon: 'remove',
- });
- });
-
- it('displays `title` prop as a tooltip', () => {
- createComponent();
-
- expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined();
- });
-
- it('has CSS class used by `remove_member_modal.vue`', () => {
- createComponent();
-
- expect(wrapper.classes()).toContain('js-remove-member-button');
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js
deleted file mode 100644
index 859fdd01043..00000000000
--- a/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { GlButton } from '@gitlab/ui';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue';
-
-jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('ResendInviteButton', () => {
- let wrapper;
-
- const createStore = (state = {}) => {
- return new Vuex.Store({
- state: {
- memberPath: '/groups/foo-bar/-/group_members/:id',
- ...state,
- },
- });
- };
-
- const createComponent = (propsData = {}, state) => {
- wrapper = shallowMount(ResendInviteButton, {
- localVue,
- store: createStore(state),
- propsData: {
- memberId: 1,
- ...propsData,
- },
- directives: {
- GlTooltip: createMockDirective(),
- },
- });
- };
-
- const findForm = () => wrapper.find('form');
- const findButton = () => findForm().find(GlButton);
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('displays a tooltip', () => {
- expect(getBinding(findButton().element, 'gl-tooltip')).not.toBeUndefined();
- expect(findButton().attributes('title')).toBe('Resend invite');
- });
-
- it('submits the form when button is clicked', () => {
- expect(findButton().attributes('type')).toBe('submit');
- });
-
- it('displays form with correct action and inputs', () => {
- expect(findForm().attributes('action')).toBe('/groups/foo-bar/-/group_members/1/resend_invite');
- expect(
- findForm()
- .find('input[name="authenticity_token"]')
- .attributes('value'),
- ).toBe('mock-csrf-token');
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js
deleted file mode 100644
index f766ad5b0d1..00000000000
--- a/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue';
-import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
-import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue';
-import { member, orphanedMember } from '../mock_data';
-
-describe('UserActionButtons', () => {
- let wrapper;
-
- const createComponent = (propsData = {}) => {
- wrapper = shallowMount(UserActionButtons, {
- propsData: {
- member,
- isCurrentUser: false,
- ...propsData,
- },
- });
- };
-
- const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when user has `canRemove` permissions', () => {
- beforeEach(() => {
- createComponent({
- permissions: {
- canRemove: true,
- },
- });
- });
-
- it('renders remove member button', () => {
- expect(findRemoveMemberButton().exists()).toBe(true);
- });
-
- it('sets props correctly', () => {
- expect(findRemoveMemberButton().props()).toEqual({
- memberId: member.id,
- message: `Are you sure you want to remove ${member.user.name} from "${member.source.name}"`,
- title: 'Remove member',
- isAccessRequest: false,
- icon: 'remove',
- });
- });
-
- describe('when member is orphaned', () => {
- it('sets `message` prop correctly', () => {
- createComponent({
- member: orphanedMember,
- permissions: {
- canRemove: true,
- },
- });
-
- expect(findRemoveMemberButton().props('message')).toBe(
- `Are you sure you want to remove this orphaned member from "${orphanedMember.source.name}"`,
- );
- });
- });
-
- describe('when member is the current user', () => {
- it('renders leave button', () => {
- createComponent({
- isCurrentUser: true,
- permissions: {
- canRemove: true,
- },
- });
-
- expect(wrapper.find(LeaveButton).exists()).toBe(true);
- });
- });
- });
-
- describe('when user does not have `canRemove` permissions', () => {
- it('does not render remove member button', () => {
- createComponent({
- permissions: {
- canRemove: false,
- },
- });
-
- expect(findRemoveMemberButton().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js
deleted file mode 100644
index d6f5773295c..00000000000
--- a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { mount, createWrapper } from '@vue/test-utils';
-import { getByText as getByTextHelper } from '@testing-library/dom';
-import { GlAvatarLink } from '@gitlab/ui';
-import { group as member } from '../mock_data';
-import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
-
-describe('MemberList', () => {
- let wrapper;
-
- const group = member.sharedWithGroup;
-
- const createComponent = (propsData = {}) => {
- wrapper = mount(GroupAvatar, {
- propsData: {
- member,
- ...propsData,
- },
- });
- };
-
- const getByText = (text, options) =>
- createWrapper(getByTextHelper(wrapper.element, text, options));
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders link to group', () => {
- const link = wrapper.find(GlAvatarLink);
-
- expect(link.exists()).toBe(true);
- expect(link.attributes('href')).toBe(group.webUrl);
- });
-
- it("renders group's full name", () => {
- expect(getByText(group.fullName).exists()).toBe(true);
- });
-
- it("renders group's avatar", () => {
- expect(wrapper.find('img').attributes('src')).toBe(group.avatarUrl);
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js
deleted file mode 100644
index 7948da7eb40..00000000000
--- a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { mount, createWrapper } from '@vue/test-utils';
-import { getByText as getByTextHelper } from '@testing-library/dom';
-import { invite as member } from '../mock_data';
-import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
-
-describe('MemberList', () => {
- let wrapper;
-
- const { invite } = member;
-
- const createComponent = (propsData = {}) => {
- wrapper = mount(InviteAvatar, {
- propsData: {
- member,
- ...propsData,
- },
- });
- };
-
- const getByText = (text, options) =>
- createWrapper(getByTextHelper(wrapper.element, text, options));
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders email as name', () => {
- expect(getByText(invite.email).exists()).toBe(true);
- });
-
- it('renders avatar', () => {
- expect(wrapper.find('img').attributes('src')).toBe(invite.avatarUrl);
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js
deleted file mode 100644
index 93d8e640968..00000000000
--- a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import { mount, createWrapper } from '@vue/test-utils';
-import { within } from '@testing-library/dom';
-import { GlAvatarLink, GlBadge } from '@gitlab/ui';
-import { member as memberMock, orphanedMember } from '../mock_data';
-import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
-
-describe('UserAvatar', () => {
- let wrapper;
-
- const { user } = memberMock;
-
- const createComponent = (propsData = {}) => {
- wrapper = mount(UserAvatar, {
- propsData: {
- member: memberMock,
- isCurrentUser: false,
- ...propsData,
- },
- });
- };
-
- const getByText = (text, options) =>
- createWrapper(within(wrapper.element).findByText(text, options));
-
- const findStatusEmoji = emoji => wrapper.find(`gl-emoji[data-name="${emoji}"]`);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it("renders link to user's profile", () => {
- createComponent();
-
- const link = wrapper.find(GlAvatarLink);
-
- expect(link.exists()).toBe(true);
- expect(link.attributes()).toMatchObject({
- href: user.webUrl,
- 'data-user-id': `${user.id}`,
- 'data-username': user.username,
- });
- });
-
- it("renders user's name", () => {
- createComponent();
-
- expect(getByText(user.name).exists()).toBe(true);
- });
-
- it("renders user's username", () => {
- createComponent();
-
- expect(getByText(`@${user.username}`).exists()).toBe(true);
- });
-
- it("renders user's avatar", () => {
- createComponent();
-
- expect(wrapper.find('img').attributes('src')).toBe(user.avatarUrl);
- });
-
- describe('when user property does not exist', () => {
- it('displays an orphaned user', () => {
- createComponent({ member: orphanedMember });
-
- expect(getByText('Orphaned member').exists()).toBe(true);
- });
- });
-
- describe('badges', () => {
- it.each`
- member | badgeText
- ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'}
- ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'}
- `('renders the "$badgeText" badge', ({ member, badgeText }) => {
- createComponent({ member });
-
- expect(wrapper.find(GlBadge).text()).toBe(badgeText);
- });
-
- it('renders the "It\'s you" badge when member is current user', () => {
- createComponent({ isCurrentUser: true });
-
- expect(getByText("It's you").exists()).toBe(true);
- });
- });
-
- describe('user status', () => {
- const emoji = 'island';
-
- describe('when set', () => {
- it('displays the status emoji', () => {
- createComponent({
- member: {
- ...memberMock,
- user: {
- ...memberMock.user,
- status: { emoji, messageHtml: 'On vacation' },
- },
- },
- });
-
- expect(findStatusEmoji(emoji).exists()).toBe(true);
- });
- });
-
- describe('when not set', () => {
- it('does not display status emoji', () => {
- createComponent();
-
- expect(findStatusEmoji(emoji).exists()).toBe(false);
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js
deleted file mode 100644
index 5674929716d..00000000000
--- a/spec/frontend/vue_shared/components/members/mock_data.js
+++ /dev/null
@@ -1,71 +0,0 @@
-export const member = {
- requestedAt: null,
- canUpdate: false,
- canRemove: false,
- canOverride: false,
- isOverridden: false,
- accessLevel: { integerValue: 50, stringValue: 'Owner' },
- source: {
- id: 178,
- name: 'Foo Bar',
- webUrl: 'https://gitlab.com/groups/foo-bar',
- },
- user: {
- id: 123,
- name: 'Administrator',
- username: 'root',
- webUrl: 'https://gitlab.com/root',
- avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon',
- blocked: false,
- twoFactorEnabled: false,
- },
- id: 238,
- createdAt: '2020-07-17T16:22:46.923Z',
- expiresAt: null,
- usingLicense: false,
- groupSso: false,
- groupManagedAccount: false,
- validRoles: {
- Guest: 10,
- Reporter: 20,
- Developer: 30,
- Maintainer: 40,
- Owner: 50,
- 'Minimal Access': 5,
- },
-};
-
-export const group = {
- accessLevel: { integerValue: 10, stringValue: 'Guest' },
- sharedWithGroup: {
- id: 24,
- name: 'Commit451',
- avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png?width=40',
- fullPath: 'parent-group/commit451',
- fullName: 'Parent group / Commit451',
- webUrl: 'https://gitlab.com/groups/parent-group/commit451',
- },
- id: 3,
- createdAt: '2020-08-06T15:31:07.662Z',
- expiresAt: null,
- validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
-};
-
-const { user, ...memberNoUser } = member;
-export const invite = {
- ...memberNoUser,
- invite: {
- email: 'jewel@hudsonwalter.biz',
- avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon',
- canResend: true,
- },
-};
-
-export const orphanedMember = memberNoUser;
-
-export const accessRequest = {
- ...member,
- requestedAt: '2020-07-17T16:22:46.923Z',
-};
-
-export const members = [member];
diff --git a/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js
deleted file mode 100644
index 63de355a3c8..00000000000
--- a/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
-import { GlModal, GlForm } from '@gitlab/ui';
-import { nextTick } from 'vue';
-import { within } from '@testing-library/dom';
-import Vuex from 'vuex';
-import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue';
-import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants';
-import { member } from '../mock_data';
-
-jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('LeaveModal', () => {
- let wrapper;
-
- const createStore = (state = {}) => {
- return new Vuex.Store({
- state: {
- memberPath: '/groups/foo-bar/-/group_members/:id',
- ...state,
- },
- });
- };
-
- const createComponent = (propsData = {}, state) => {
- wrapper = mount(LeaveModal, {
- localVue,
- store: createStore(state),
- propsData: {
- member,
- ...propsData,
- },
- attrs: {
- static: true,
- visible: true,
- },
- });
- };
-
- const findModal = () => wrapper.find(GlModal);
-
- const findForm = () => findModal().find(GlForm);
-
- const getByText = (text, options) =>
- createWrapper(within(findModal().element).getByText(text, options));
-
- beforeEach(async () => {
- createComponent();
- await nextTick();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('sets modal ID', () => {
- expect(findModal().props('modalId')).toBe(LEAVE_MODAL_ID);
- });
-
- it('displays modal title', () => {
- expect(getByText(`Leave "${member.source.name}"`).exists()).toBe(true);
- });
-
- it('displays modal body', () => {
- expect(getByText(`Are you sure you want to leave "${member.source.name}"?`).exists()).toBe(
- true,
- );
- });
-
- it('displays form with correct action and inputs', () => {
- const form = findForm();
-
- expect(form.attributes('action')).toBe('/groups/foo-bar/-/group_members/leave');
- expect(form.find('input[name="_method"]').attributes('value')).toBe('delete');
- expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
- 'mock-csrf-token',
- );
- });
-
- it('submits the form when "Leave" button is clicked', () => {
- const submitSpy = jest.spyOn(findForm().element, 'submit');
-
- getByText('Leave').trigger('click');
-
- expect(submitSpy).toHaveBeenCalled();
-
- submitSpy.mockRestore();
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js
deleted file mode 100644
index 84da051792d..00000000000
--- a/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
-import { GlModal, GlForm } from '@gitlab/ui';
-import { nextTick } from 'vue';
-import { within } from '@testing-library/dom';
-import Vuex from 'vuex';
-import RemoveGroupLinkModal from '~/vue_shared/components/members/modals/remove_group_link_modal.vue';
-import { REMOVE_GROUP_LINK_MODAL_ID } from '~/vue_shared/components/members/constants';
-import { group } from '../mock_data';
-
-jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('RemoveGroupLinkModal', () => {
- let wrapper;
-
- const actions = {
- hideRemoveGroupLinkModal: jest.fn(),
- };
-
- const createStore = (state = {}) => {
- return new Vuex.Store({
- state: {
- memberPath: '/groups/foo-bar/-/group_links/:id',
- groupLinkToRemove: group,
- removeGroupLinkModalVisible: true,
- ...state,
- },
- actions,
- });
- };
-
- const createComponent = state => {
- wrapper = mount(RemoveGroupLinkModal, {
- localVue,
- store: createStore(state),
- attrs: {
- static: true,
- },
- });
- };
-
- const findModal = () => wrapper.find(GlModal);
- const findForm = () => findModal().find(GlForm);
- const getByText = (text, options) =>
- createWrapper(within(findModal().element).getByText(text, options));
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('when modal is open', () => {
- beforeEach(async () => {
- createComponent();
- await nextTick();
- });
-
- it('sets modal ID', () => {
- expect(findModal().props('modalId')).toBe(REMOVE_GROUP_LINK_MODAL_ID);
- });
-
- it('displays modal title', () => {
- expect(getByText(`Remove "${group.sharedWithGroup.fullName}"`).exists()).toBe(true);
- });
-
- it('displays modal body', () => {
- expect(
- getByText(`Are you sure you want to remove "${group.sharedWithGroup.fullName}"?`).exists(),
- ).toBe(true);
- });
-
- it('displays form with correct action and inputs', () => {
- const form = findForm();
-
- expect(form.attributes('action')).toBe(`/groups/foo-bar/-/group_links/${group.id}`);
- expect(form.find('input[name="_method"]').attributes('value')).toBe('delete');
- expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
- 'mock-csrf-token',
- );
- });
-
- it('submits the form when "Remove group" button is clicked', () => {
- const submitSpy = jest.spyOn(findForm().element, 'submit');
-
- getByText('Remove group').trigger('click');
-
- expect(submitSpy).toHaveBeenCalled();
-
- submitSpy.mockRestore();
- });
-
- it('calls `hideRemoveGroupLinkModal` action when modal is closed', () => {
- getByText('Cancel').trigger('click');
-
- expect(actions.hideRemoveGroupLinkModal).toHaveBeenCalled();
- });
- });
-
- it('modal does not show when `removeGroupLinkModalVisible` is `false`', () => {
- createComponent({ removeGroupLinkModalVisible: false });
-
- expect(findModal().vm.$attrs.visible).toBe(false);
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/table/created_at_spec.js b/spec/frontend/vue_shared/components/members/table/created_at_spec.js
deleted file mode 100644
index cf3821baf44..00000000000
--- a/spec/frontend/vue_shared/components/members/table/created_at_spec.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { mount, createWrapper } from '@vue/test-utils';
-import { within } from '@testing-library/dom';
-import { useFakeDate } from 'helpers/fake_date';
-import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-
-describe('CreatedAt', () => {
- // March 15th, 2020
- useFakeDate(2020, 2, 15);
-
- const date = '2020-03-01T00:00:00.000';
- const dateTimeAgo = '2 weeks ago';
-
- let wrapper;
-
- const createComponent = propsData => {
- wrapper = mount(CreatedAt, {
- propsData: {
- date,
- ...propsData,
- },
- });
- };
-
- const getByText = (text, options) =>
- createWrapper(within(wrapper.element).getByText(text, options));
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('created at text', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('displays created at text', () => {
- expect(getByText(dateTimeAgo).exists()).toBe(true);
- });
-
- it('uses `TimeAgoTooltip` component to display tooltip', () => {
- expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
- });
- });
-
- describe('when `createdBy` prop is provided', () => {
- it('displays a link to the user that created the member', () => {
- createComponent({
- createdBy: {
- name: 'Administrator',
- webUrl: 'https://gitlab.com/root',
- },
- });
-
- const link = getByText('Administrator');
-
- expect(link.exists()).toBe(true);
- expect(link.attributes('href')).toBe('https://gitlab.com/root');
- });
- });
-});
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
deleted file mode 100644
index a1afdbc2b49..00000000000
--- a/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js
+++ /dev/null
@@ -1,166 +0,0 @@
-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/expires_at_spec.js b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js
deleted file mode 100644
index 95ae251b0fd..00000000000
--- a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { mount, createWrapper } from '@vue/test-utils';
-import { within } from '@testing-library/dom';
-import { useFakeDate } from 'helpers/fake_date';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
-
-describe('ExpiresAt', () => {
- // March 15th, 2020
- useFakeDate(2020, 2, 15);
-
- let wrapper;
-
- const createComponent = propsData => {
- wrapper = mount(ExpiresAt, {
- propsData,
- directives: {
- GlTooltip: createMockDirective(),
- },
- });
- };
-
- const getByText = (text, options) =>
- createWrapper(within(wrapper.element).getByText(text, options));
-
- const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip');
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when no expiration date is set', () => {
- it('displays "No expiration set"', () => {
- createComponent({ date: null });
-
- expect(getByText('No expiration set').exists()).toBe(true);
- });
- });
-
- describe('when expiration date is in the past', () => {
- let expiredText;
-
- beforeEach(() => {
- createComponent({ date: '2019-03-15T00:00:00.000' });
-
- expiredText = getByText('Expired');
- });
-
- it('displays "Expired"', () => {
- expect(expiredText.exists()).toBe(true);
- expect(expiredText.classes()).toContain('gl-text-red-500');
- });
-
- it('displays tooltip with formatted date', () => {
- const tooltipDirective = getTooltipDirective(expiredText);
-
- expect(tooltipDirective).not.toBeUndefined();
- expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000');
- });
- });
-
- describe('when expiration date is in the future', () => {
- it.each`
- date | expected | warningColor
- ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false}
- ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true}
- ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true}
- ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true}
- ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true}
- ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true}
- ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true}
- ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true}
- `('displays "$expected"', ({ date, expected, warningColor }) => {
- createComponent({ date });
-
- const expiredText = getByText(expected);
-
- expect(expiredText.exists()).toBe(true);
-
- if (warningColor) {
- expect(expiredText.classes()).toContain('gl-text-orange-500');
- } else {
- expect(expiredText.classes()).not.toContain('gl-text-orange-500');
- }
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js
deleted file mode 100644
index e55d9b6be2a..00000000000
--- a/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
-import { member as memberMock, group, invite, accessRequest } from '../mock_data';
-import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue';
-import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue';
-import GroupActionButtons from '~/vue_shared/components/members/action_buttons/group_action_buttons.vue';
-import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue';
-import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue';
-
-describe('MemberActionButtons', () => {
- let wrapper;
-
- const createComponent = (propsData = {}) => {
- wrapper = shallowMount(MemberActionButtons, {
- propsData: {
- isCurrentUser: false,
- permissions: {
- canRemove: true,
- },
- ...propsData,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- test.each`
- memberType | member | expectedComponent | expectedComponentName
- ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'}
- ${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'}
- ${MEMBER_TYPES.invite} | ${invite} | ${InviteActionButtons} | ${'InviteActionButtons'}
- ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${AccessRequestActionButtons} | ${'AccessRequestActionButtons'}
- `(
- 'renders $expectedComponentName when `memberType` is $memberType',
- ({ memberType, member, expectedComponent }) => {
- createComponent({ memberType, member });
-
- expect(wrapper.find(expectedComponent).exists()).toBe(true);
- },
- );
-});
diff --git a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js
deleted file mode 100644
index a171dd830c1..00000000000
--- a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
-import { member as memberMock, group, invite, accessRequest } from '../mock_data';
-import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
-import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
-import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
-import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
-
-describe('MemberList', () => {
- let wrapper;
-
- const createComponent = propsData => {
- wrapper = shallowMount(MemberAvatar, {
- propsData: {
- isCurrentUser: false,
- ...propsData,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- test.each`
- memberType | member | expectedComponent | expectedComponentName
- ${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'}
- ${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'}
- ${MEMBER_TYPES.invite} | ${invite} | ${InviteAvatar} | ${'InviteAvatar'}
- ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${UserAvatar} | ${'UserAvatar'}
- `(
- 'renders $expectedComponentName when `memberType` is $memberType',
- ({ memberType, member, expectedComponent }) => {
- createComponent({ memberType, member });
-
- expect(wrapper.find(expectedComponent).exists()).toBe(true);
- },
- );
-});
diff --git a/spec/frontend/vue_shared/components/members/table/member_source_spec.js b/spec/frontend/vue_shared/components/members/table/member_source_spec.js
deleted file mode 100644
index 8b914d76674..00000000000
--- a/spec/frontend/vue_shared/components/members/table/member_source_spec.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { mount, createWrapper } from '@vue/test-utils';
-import { getByText as getByTextHelper } from '@testing-library/dom';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
-
-describe('MemberSource', () => {
- let wrapper;
-
- const createComponent = propsData => {
- wrapper = mount(MemberSource, {
- propsData: {
- memberSource: {
- id: 102,
- name: 'Foo bar',
- webUrl: 'https://gitlab.com/groups/foo-bar',
- },
- ...propsData,
- },
- directives: {
- GlTooltip: createMockDirective(),
- },
- });
- };
-
- const getByText = (text, options) =>
- createWrapper(getByTextHelper(wrapper.element, text, options));
-
- const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip');
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('direct member', () => {
- it('displays "Direct member"', () => {
- createComponent({
- isDirectMember: true,
- });
-
- expect(getByText('Direct member').exists()).toBe(true);
- });
- });
-
- describe('inherited member', () => {
- let sourceGroupLink;
-
- beforeEach(() => {
- createComponent({
- isDirectMember: false,
- });
-
- sourceGroupLink = getByText('Foo bar');
- });
-
- it('displays a link to source group', () => {
- createComponent({
- isDirectMember: false,
- });
-
- expect(sourceGroupLink.exists()).toBe(true);
- expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar');
- });
-
- it('displays tooltip with "Inherited"', () => {
- const tooltipDirective = getTooltipDirective(sourceGroupLink);
-
- expect(tooltipDirective).not.toBeUndefined();
- expect(sourceGroupLink.attributes('title')).toBe('Inherited');
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js
deleted file mode 100644
index ba693975a88..00000000000
--- a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js
+++ /dev/null
@@ -1,251 +0,0 @@
-import { mount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
-import { member as memberMock, group, invite, accessRequest } from '../mock_data';
-import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue';
-
-describe('MemberList', () => {
- const WrappedComponent = {
- props: {
- memberType: {
- type: String,
- required: true,
- },
- isDirectMember: {
- type: Boolean,
- required: true,
- },
- isCurrentUser: {
- type: Boolean,
- required: true,
- },
- permissions: {
- type: Object,
- required: true,
- },
- },
- render(createElement) {
- return createElement('div', this.memberType);
- },
- };
-
- const localVue = createLocalVue();
- localVue.use(Vuex);
- localVue.component('wrapped-component', WrappedComponent);
-
- const createStore = (state = {}) => {
- return new Vuex.Store({
- state: {
- sourceId: 1,
- currentUserId: 1,
- ...state,
- },
- });
- };
-
- let wrapper;
-
- const createComponent = (propsData, state = {}) => {
- wrapper = mount(MembersTableCell, {
- localVue,
- propsData,
- store: createStore(state),
- scopedSlots: {
- default: `
- <wrapped-component
- :member-type="props.memberType"
- :is-direct-member="props.isDirectMember"
- :is-current-user="props.isCurrentUser"
- :permissions="props.permissions"
- />
- `,
- },
- });
- };
-
- const findWrappedComponent = () => wrapper.find(WrappedComponent);
-
- const memberCurrentUser = {
- ...memberMock,
- user: {
- ...memberMock.user,
- id: 1,
- },
- };
-
- const createComponentWithDirectMember = (member = {}) => {
- createComponent({
- member: {
- ...memberMock,
- source: {
- ...memberMock.source,
- id: 1,
- },
- ...member,
- },
- });
- };
- const createComponentWithInheritedMember = (member = {}) => {
- createComponent({
- member: { ...memberMock, ...member },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- test.each`
- member | expectedMemberType
- ${memberMock} | ${MEMBER_TYPES.user}
- ${group} | ${MEMBER_TYPES.group}
- ${invite} | ${MEMBER_TYPES.invite}
- ${accessRequest} | ${MEMBER_TYPES.accessRequest}
- `(
- 'sets scoped slot prop `memberType` to $expectedMemberType',
- ({ member, expectedMemberType }) => {
- createComponent({ member });
-
- expect(findWrappedComponent().props('memberType')).toBe(expectedMemberType);
- },
- );
-
- describe('isDirectMember', () => {
- it('returns `true` when member source has same ID as `sourceId`', () => {
- createComponentWithDirectMember();
-
- expect(findWrappedComponent().props('isDirectMember')).toBe(true);
- });
-
- it('returns `false` when member is inherited', () => {
- createComponentWithInheritedMember();
-
- expect(findWrappedComponent().props('isDirectMember')).toBe(false);
- });
-
- it('returns `true` for linked groups', () => {
- createComponent({
- member: group,
- });
-
- expect(findWrappedComponent().props('isDirectMember')).toBe(true);
- });
- });
-
- describe('isCurrentUser', () => {
- it('returns `true` when `member.user` has the same ID as `currentUserId`', () => {
- createComponent({
- member: memberCurrentUser,
- });
-
- expect(findWrappedComponent().props('isCurrentUser')).toBe(true);
- });
-
- it('returns `false` when `member.user` does not have the same ID as `currentUserId`', () => {
- createComponent({
- member: memberMock,
- });
-
- expect(findWrappedComponent().props('isCurrentUser')).toBe(false);
- });
- });
-
- describe('permissions', () => {
- describe('canRemove', () => {
- describe('for a direct member', () => {
- it('returns `true` when `canRemove` is `true`', () => {
- createComponentWithDirectMember({
- canRemove: true,
- });
-
- expect(findWrappedComponent().props('permissions').canRemove).toBe(true);
- });
-
- it('returns `false` when `canRemove` is `false`', () => {
- createComponentWithDirectMember({
- canRemove: false,
- });
-
- expect(findWrappedComponent().props('permissions').canRemove).toBe(false);
- });
- });
-
- describe('for an inherited member', () => {
- it('returns `false`', () => {
- createComponentWithInheritedMember();
-
- expect(findWrappedComponent().props('permissions').canRemove).toBe(false);
- });
- });
- });
-
- describe('canResend', () => {
- describe('when member type is `invite`', () => {
- it('returns `true` when `canResend` is `true`', () => {
- createComponent({
- member: invite,
- });
-
- expect(findWrappedComponent().props('permissions').canResend).toBe(true);
- });
-
- it('returns `false` when `canResend` is `false`', () => {
- createComponent({
- member: {
- ...invite,
- invite: {
- ...invite,
- canResend: false,
- },
- },
- });
-
- expect(findWrappedComponent().props('permissions').canResend).toBe(false);
- });
- });
-
- describe('when member type is not `invite`', () => {
- it('returns `false`', () => {
- createComponent({ member: memberMock });
-
- expect(findWrappedComponent().props('permissions').canResend).toBe(false);
- });
- });
- });
-
- describe('canUpdate', () => {
- describe('for a direct member', () => {
- it('returns `true` when `canUpdate` is `true`', () => {
- createComponentWithDirectMember({
- canUpdate: true,
- });
-
- expect(findWrappedComponent().props('permissions').canUpdate).toBe(true);
- });
-
- it('returns `false` when `canUpdate` is `false`', () => {
- createComponentWithDirectMember({
- canUpdate: false,
- });
-
- expect(findWrappedComponent().props('permissions').canUpdate).toBe(false);
- });
-
- it('returns `false` for current user', () => {
- createComponentWithDirectMember(memberCurrentUser);
-
- expect(findWrappedComponent().props('permissions').canUpdate).toBe(false);
- });
- });
-
- describe('for an inherited member', () => {
- it('returns `false`', () => {
- createComponentWithInheritedMember();
-
- expect(findWrappedComponent().props('permissions').canUpdate).toBe(false);
- });
- });
- });
- });
-});
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
deleted file mode 100644
index e593e88438c..00000000000
--- a/spec/frontend/vue_shared/components/members/table/members_table_spec.js
+++ /dev/null
@@ -1,212 +0,0 @@
-import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
-import Vuex from 'vuex';
-import {
- getByText as getByTextHelper,
- getByTestId as getByTestIdHelper,
- within,
-} from '@testing-library/dom';
-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';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('MemberList', () => {
- let wrapper;
-
- const createStore = (state = {}) => {
- return new Vuex.Store({
- state: {
- members: [],
- tableFields: [],
- tableAttrs: {
- table: { 'data-qa-selector': 'members_list' },
- tr: { 'data-qa-selector': 'member_row' },
- },
- sourceId: 1,
- currentUserId: 1,
- ...state,
- },
- });
- };
-
- const createComponent = state => {
- wrapper = mount(MembersTable, {
- localVue,
- store: createStore(state),
- stubs: [
- 'member-avatar',
- 'member-source',
- 'expires-at',
- 'created-at',
- 'member-action-buttons',
- 'role-dropdown',
- 'remove-group-link-modal',
- 'expiration-datepicker',
- ],
- });
- };
-
- const getByText = (text, options) =>
- createWrapper(getByTextHelper(wrapper.element, text, options));
-
- const getByTestId = (id, options) =>
- createWrapper(getByTestIdHelper(wrapper.element, id, options));
-
- const findTable = () => wrapper.find(GlTable);
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('fields', () => {
- const directMember = {
- ...memberMock,
- source: { ...memberMock.source, id: 1 },
- };
-
- const memberCanUpdate = {
- ...directMember,
- canUpdate: true,
- };
-
- it.each`
- field | label | member | expectedComponent
- ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
- ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
- ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
- ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
- ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
- ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt}
- ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
- ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
- `('renders the $label field', ({ field, label, member, expectedComponent }) => {
- createComponent({
- members: [member],
- tableFields: [field],
- });
-
- expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true);
-
- if (expectedComponent) {
- expect(
- wrapper
- .find(`[data-label="${label}"][role="cell"]`)
- .find(expectedComponent)
- .exists(),
- ).toBe(true);
- }
- });
-
- describe('"Actions" field', () => {
- it('renders "Actions" field for screen readers', () => {
- createComponent({ members: [memberCanUpdate], tableFields: ['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);
- });
-
- 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);
- });
- });
- });
- });
-
- describe('when `members` is an empty array', () => {
- it('displays a "No members found" message', () => {
- createComponent();
-
- expect(getByText('No members found').exists()).toBe(true);
- });
- });
-
- describe('when member can not be updated', () => {
- it('renders badge in "Max role" field', () => {
- createComponent({ members: [memberMock], tableFields: ['maxRole'] });
-
- expect(
- wrapper
- .find(`[data-label="Max role"][role="cell"]`)
- .find(GlBadge)
- .text(),
- ).toBe(memberMock.accessLevel.stringValue);
- });
- });
-
- it('initializes user popovers when mounted', () => {
- const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default');
-
- createComponent();
-
- 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
deleted file mode 100644
index 55ec7000693..00000000000
--- a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js
+++ /dev/null
@@ -1,151 +0,0 @@
-import { mount, createWrapper, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-import { nextTick } from 'vue';
-import { within } from '@testing-library/dom';
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import waitForPromises from 'helpers/wait_for_promises';
-import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue';
-import { member } from '../mock_data';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('RoleDropdown', () => {
- let wrapper;
- let actions;
- const $toast = {
- show: jest.fn(),
- };
-
- const createStore = () => {
- actions = {
- updateMemberRole: jest.fn(() => Promise.resolve()),
- };
-
- return new Vuex.Store({ actions });
- };
-
- const createComponent = (propsData = {}) => {
- wrapper = mount(RoleDropdown, {
- propsData: {
- member,
- permissions: {},
- ...propsData,
- },
- localVue,
- store: createStore(),
- mocks: {
- $toast,
- },
- });
- };
-
- const getDropdownMenu = () => within(wrapper.element).getByRole('menu');
- const getByTextInDropdownMenu = (text, options = {}) =>
- createWrapper(within(getDropdownMenu()).getByText(text, options));
- const getDropdownItemByText = text =>
- createWrapper(
- within(getDropdownMenu())
- .getByText(text, { selector: '[role="menuitem"] p' })
- .closest('[role="menuitem"]'),
- );
- const getCheckedDropdownItem = () =>
- wrapper
- .findAll(GlDropdownItem)
- .wrappers.find(dropdownItemWrapper => dropdownItemWrapper.props('isChecked'));
-
- const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]');
- const findDropdown = () => wrapper.find(GlDropdown);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when dropdown is open', () => {
- beforeEach(done => {
- createComponent();
-
- findDropdownToggle().trigger('click');
- wrapper.vm.$root.$on('bv::dropdown::shown', () => {
- done();
- });
- });
-
- it('renders all valid roles', () => {
- Object.keys(member.validRoles).forEach(role => {
- expect(getDropdownItemByText(role).exists()).toBe(true);
- });
- });
-
- it('renders dropdown header', () => {
- expect(getByTextInDropdownMenu('Change permissions').exists()).toBe(true);
- });
-
- it('sets dropdown toggle and checks selected role', () => {
- expect(findDropdownToggle().text()).toBe('Owner');
- expect(getCheckedDropdownItem().text()).toBe('Owner');
- });
-
- describe('when dropdown item is selected', () => {
- it('does nothing if the item selected was already selected', () => {
- getDropdownItemByText('Owner').trigger('click');
-
- expect(actions.updateMemberRole).not.toHaveBeenCalled();
- });
-
- it('calls `updateMemberRole` Vuex action', () => {
- getDropdownItemByText('Developer').trigger('click');
-
- expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), {
- memberId: member.id,
- accessLevel: { integerValue: 30, stringValue: 'Developer' },
- });
- });
-
- it('displays toast when successful', async () => {
- getDropdownItemByText('Developer').trigger('click');
-
- await waitForPromises();
-
- expect($toast.show).toHaveBeenCalledWith('Role updated successfully.');
- });
-
- it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => {
- getDropdownItemByText('Developer').trigger('click');
-
- await nextTick();
-
- expect(findDropdown().props('disabled')).toBe(true);
-
- await waitForPromises();
-
- expect(findDropdown().props('disabled')).toBe(false);
- });
- });
- });
-
- it("sets initial dropdown toggle value to member's role", () => {
- createComponent();
-
- expect(findDropdownToggle().text()).toBe('Owner');
- });
-
- it('sets the dropdown alignment to right on mobile', async () => {
- jest.spyOn(bp, 'isDesktop').mockReturnValue(false);
- createComponent();
-
- await nextTick();
-
- expect(findDropdown().attributes('right')).toBe('true');
- });
-
- it('sets the dropdown alignment to left on desktop', async () => {
- jest.spyOn(bp, 'isDesktop').mockReturnValue(true);
- createComponent();
-
- await nextTick();
-
- expect(findDropdown().attributes('right')).toBeUndefined();
- });
-});
diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js
deleted file mode 100644
index 3f2b2097133..00000000000
--- a/spec/frontend/vue_shared/components/members/utils_spec.js
+++ /dev/null
@@ -1,122 +0,0 @@
-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', () => {
- it('has correct properties for each badge', () => {
- const badges = generateBadges(memberMock, true);
-
- badges.forEach(badge => {
- expect(badge).toEqual(
- expect.objectContaining({
- show: expect.any(Boolean),
- text: expect.any(String),
- variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/),
- }),
- );
- });
- });
-
- it.each`
- member | expected
- ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }}
- ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }}
- ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }}
- `('returns expected output for "$expected.text" badge', ({ member, expected }) => {
- 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/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
index ecea151fc8a..da49778f216 100644
--- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
+++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
@@ -47,6 +47,7 @@ exports[`Package code instruction single line to match the default snapshot 1`]
<!---->
<svg
+ aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="copy-to-clipboard-icon"
>
diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap
index 3990248d021..623f7d083c5 100644
--- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap
+++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap
@@ -10,6 +10,9 @@ exports[`Resizable Skeleton Loader default setup renders the bars, labels, and g
version="1.1"
viewBox="0 0 400 130"
>
+ <title>
+ Loading
+ </title>
<rect
clip-path="url(#null-idClip)"
height="130"
@@ -226,6 +229,9 @@ exports[`Resizable Skeleton Loader with custom settings renders the correct posi
version="1.1"
viewBox="0 0 400 130"
>
+ <title>
+ Loading
+ </title>
<rect
clip-path="url(#-idClip)"
height="130"
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 d50cf2915e8..cd1157a1c2e 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
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { mockEditorApi } from '@toast-ui/vue-editor';
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue';
import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue';
@@ -114,10 +115,17 @@ describe('Rich Content Editor', () => {
});
describe('when editor is loaded', () => {
+ const formattedMarkdown = 'formatted markdown';
+
beforeEach(() => {
+ mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown);
buildWrapper();
});
+ afterEach(() => {
+ mockEditorApi.getMarkdown.mockReset();
+ });
+
it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => {
expect(addCustomEventListener).toHaveBeenCalledWith(
wrapper.vm.editorApi,
@@ -137,6 +145,11 @@ describe('Rich Content Editor', () => {
it('registers HTML to markdown renderer', () => {
expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi);
});
+
+ it('emits load event with the markdown formatted by Toast UI', () => {
+ expect(mockEditorApi.getMarkdown).toHaveBeenCalled();
+ expect(wrapper.emitted('load')[0]).toEqual([{ formattedMarkdown }]);
+ });
});
describe('when editor is destroyed', () => {
diff --git a/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap b/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap
new file mode 100644
index 00000000000..1e08394dd56
--- /dev/null
+++ b/spec/frontend/vue_shared/components/security_reports/__snapshots__/security_summary_spec.js.snap
@@ -0,0 +1,144 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SecuritySummary component given the message {"countMessage": "%{criticalStart}0 Critical%{criticalEnd} %{highStart}1 High%{highEnd} and %{otherStart}0 Others%{otherEnd}", "critical": 0, "high": 1, "message": "Security scanning detected %{totalStart}1%{totalEnd} potential vulnerability", "other": 0, "status": "", "total": 1} interpolates correctly 1`] = `
+<span>
+ Security scanning detected
+ <strong>
+ 1
+ </strong>
+ potential vulnerability
+ <span
+ class="gl-font-sm"
+ >
+ <span>
+ <span
+ class="gl-pl-4"
+ >
+
+ 0 Critical
+
+ </span>
+ </span>
+
+ <span>
+ <strong
+ class="text-danger-600 gl-px-2"
+ >
+
+ 1 High
+
+ </strong>
+ </span>
+ and
+ <span>
+ <span
+ class="gl-px-2"
+ >
+
+ 0 Others
+
+ </span>
+ </span>
+ </span>
+</span>
+`;
+
+exports[`SecuritySummary component given the message {"countMessage": "%{criticalStart}1 Critical%{criticalEnd} %{highStart}0 High%{highEnd} and %{otherStart}0 Others%{otherEnd}", "critical": 1, "high": 0, "message": "Security scanning detected %{totalStart}1%{totalEnd} potential vulnerability", "other": 0, "status": "", "total": 1} interpolates correctly 1`] = `
+<span>
+ Security scanning detected
+ <strong>
+ 1
+ </strong>
+ potential vulnerability
+ <span
+ class="gl-font-sm"
+ >
+ <span>
+ <strong
+ class="text-danger-800 gl-pl-4"
+ >
+
+ 1 Critical
+
+ </strong>
+ </span>
+
+ <span>
+ <span
+ class="gl-px-2"
+ >
+
+ 0 High
+
+ </span>
+ </span>
+ and
+ <span>
+ <span
+ class="gl-px-2"
+ >
+
+ 0 Others
+
+ </span>
+ </span>
+ </span>
+</span>
+`;
+
+exports[`SecuritySummary component given the message {"countMessage": "%{criticalStart}1 Critical%{criticalEnd} %{highStart}2 High%{highEnd} and %{otherStart}0 Others%{otherEnd}", "critical": 1, "high": 2, "message": "Security scanning detected %{totalStart}3%{totalEnd} potential vulnerabilities", "other": 0, "status": "", "total": 3} interpolates correctly 1`] = `
+<span>
+ Security scanning detected
+ <strong>
+ 3
+ </strong>
+ potential vulnerabilities
+ <span
+ class="gl-font-sm"
+ >
+ <span>
+ <strong
+ class="text-danger-800 gl-pl-4"
+ >
+
+ 1 Critical
+
+ </strong>
+ </span>
+
+ <span>
+ <strong
+ class="text-danger-600 gl-px-2"
+ >
+
+ 2 High
+
+ </strong>
+ </span>
+ and
+ <span>
+ <span
+ class="gl-px-2"
+ >
+
+ 0 Others
+
+ </span>
+ </span>
+ </span>
+</span>
+`;
+
+exports[`SecuritySummary component given the message {"message": ""} interpolates correctly 1`] = `
+<span>
+
+ <!---->
+</span>
+`;
+
+exports[`SecuritySummary component given the message {"message": "foo"} interpolates correctly 1`] = `
+<span>
+ foo
+ <!---->
+</span>
+`;
diff --git a/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js b/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js
new file mode 100644
index 00000000000..60203493cbd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/security_reports/help_icon_spec.js
@@ -0,0 +1,68 @@
+import { GlLink, GlPopover } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue';
+
+const helpPath = '/docs';
+const discoverProjectSecurityPath = '/discoverProjectSecurityPath';
+
+describe('HelpIcon component', () => {
+ let wrapper;
+
+ const createWrapper = props => {
+ wrapper = shallowMount(HelpIcon, {
+ propsData: {
+ helpPath,
+ ...props,
+ },
+ });
+ };
+
+ const findLink = () => wrapper.find(GlLink);
+ const findPopover = () => wrapper.find(GlPopover);
+ const findPopoverTarget = () => wrapper.find({ ref: 'discoverProjectSecurity' });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('given a help path only', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('does not render a popover', () => {
+ expect(findPopover().exists()).toBe(false);
+ });
+
+ it('renders a help link', () => {
+ expect(findLink().attributes()).toMatchObject({
+ href: helpPath,
+ target: '_blank',
+ });
+ });
+ });
+
+ describe('given a help path and discover project security path', () => {
+ beforeEach(() => {
+ createWrapper({ discoverProjectSecurityPath });
+ });
+
+ it('renders a popover', () => {
+ const popover = findPopover();
+ expect(popover.props('target')()).toBe(findPopoverTarget().element);
+ expect(popover.attributes()).toMatchObject({
+ title: HelpIcon.i18n.upgradeToManageVulnerabilities,
+ triggers: 'click blur',
+ });
+ expect(popover.text()).toContain(HelpIcon.i18n.upgradeToInteract);
+ });
+
+ it('renders a link to the discover path', () => {
+ expect(findLink().attributes()).toMatchObject({
+ href: discoverProjectSecurityPath,
+ target: '_blank',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js b/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js
new file mode 100644
index 00000000000..e57152c3cbf
--- /dev/null
+++ b/spec/frontend/vue_shared/components/security_reports/security_summary_spec.js
@@ -0,0 +1,38 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import SecuritySummary from '~/vue_shared/security_reports/components/security_summary.vue';
+import { groupedTextBuilder } from '~/vue_shared/security_reports/store/utils';
+
+describe('SecuritySummary component', () => {
+ let wrapper;
+
+ const createWrapper = message => {
+ wrapper = shallowMount(SecuritySummary, {
+ propsData: { message },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe.each([
+ { message: '' },
+ { message: 'foo' },
+ groupedTextBuilder({ reportType: 'Security scanning', critical: 1, high: 0, total: 1 }),
+ groupedTextBuilder({ reportType: 'Security scanning', critical: 0, high: 1, total: 1 }),
+ groupedTextBuilder({ reportType: 'Security scanning', critical: 1, high: 2, total: 3 }),
+ ])('given the message %p', message => {
+ beforeEach(() => {
+ createWrapper(message);
+ });
+
+ it('interpolates correctly', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js
index 9db86fa775f..596cb22fca5 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js
@@ -33,8 +33,8 @@ describe('BaseComponent', () => {
expect(vm.hiddenInputName).toBe('issue[label_names][]');
});
- it('returns correct string when showCreate prop is `false`', () => {
- wrapper.setProps({ showCreate: false });
+ it('returns correct string when showCreate prop is `false`', async () => {
+ await wrapper.setProps({ showCreate: false });
expect(vm.hiddenInputName).toBe('label_id[]');
});
@@ -45,8 +45,8 @@ describe('BaseComponent', () => {
expect(vm.createLabelTitle).toBe('Create project label');
});
- it('return `Create group label` when `isProject` prop is false', () => {
- wrapper.setProps({ isProject: false });
+ it('return `Create group label` when `isProject` prop is false', async () => {
+ await wrapper.setProps({ isProject: false });
expect(vm.createLabelTitle).toBe('Create group label');
});
@@ -57,8 +57,8 @@ describe('BaseComponent', () => {
expect(vm.manageLabelsTitle).toBe('Manage project labels');
});
- it('return `Manage group labels` when `isProject` prop is false', () => {
- wrapper.setProps({ isProject: false });
+ it('return `Manage group labels` when `isProject` prop is false', async () => {
+ await wrapper.setProps({ isProject: false });
expect(vm.manageLabelsTitle).toBe('Manage group labels');
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
index 8c17a974b39..1206450bbeb 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
@@ -20,22 +20,24 @@ jest.mock('~/lib/utils/common_utils', () => ({
const localVue = createLocalVue();
localVue.use(Vuex);
-const createComponent = (config = mockConfig, slots = {}) =>
- shallowMount(LabelsSelectRoot, {
- localVue,
- slots,
- store: new Vuex.Store(labelsSelectModule()),
- propsData: config,
- stubs: {
- 'dropdown-contents': DropdownContents,
- },
- });
-
describe('LabelsSelectRoot', () => {
let wrapper;
+ let store;
+
+ const createComponent = (config = mockConfig, slots = {}) => {
+ wrapper = shallowMount(LabelsSelectRoot, {
+ localVue,
+ slots,
+ store,
+ propsData: config,
+ stubs: {
+ 'dropdown-contents': DropdownContents,
+ },
+ });
+ };
beforeEach(() => {
- wrapper = createComponent();
+ store = new Vuex.Store(labelsSelectModule());
});
afterEach(() => {
@@ -45,6 +47,7 @@ describe('LabelsSelectRoot', () => {
describe('methods', () => {
describe('handleVuexActionDispatch', () => {
it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => {
+ createComponent();
jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
wrapper.vm.handleVuexActionDispatch(
@@ -67,7 +70,7 @@ describe('LabelsSelectRoot', () => {
});
it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => {
- wrapper = createComponent({
+ createComponent({
...mockConfig,
variant: 'embedded',
});
@@ -95,6 +98,10 @@ describe('LabelsSelectRoot', () => {
});
describe('handleDropdownClose', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => {
wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]);
@@ -112,6 +119,7 @@ describe('LabelsSelectRoot', () => {
describe('handleCollapsedValueClick', () => {
it('emits `toggleCollapse` event on component', () => {
+ createComponent();
wrapper.vm.handleCollapsedValueClick();
expect(wrapper.emitted().toggleCollapse).toBeTruthy();
@@ -121,6 +129,7 @@ describe('LabelsSelectRoot', () => {
describe('template', () => {
it('renders component with classes `labels-select-wrapper position-relative`', () => {
+ createComponent();
expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
});
@@ -131,7 +140,7 @@ describe('LabelsSelectRoot', () => {
`(
'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
({ variant, cssClass }) => {
- wrapper = createComponent({
+ createComponent({
...mockConfig,
variant,
});
@@ -142,57 +151,58 @@ describe('LabelsSelectRoot', () => {
},
);
- it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => {
+ it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => {
+ createComponent();
+ await wrapper.vm.$nextTick;
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
});
- it('renders `dropdown-title` component', () => {
+ it('renders `dropdown-title` component', async () => {
+ createComponent();
+ await wrapper.vm.$nextTick;
expect(wrapper.find(DropdownTitle).exists()).toBe(true);
});
- it('renders `dropdown-value` component', () => {
- const wrapperDropdownValue = createComponent(mockConfig, {
+ it('renders `dropdown-value` component', async () => {
+ createComponent(mockConfig, {
default: 'None',
});
+ await wrapper.vm.$nextTick;
- return wrapperDropdownValue.vm.$nextTick(() => {
- const valueComp = wrapperDropdownValue.find(DropdownValue);
+ const valueComp = wrapper.find(DropdownValue);
- expect(valueComp.exists()).toBe(true);
- expect(valueComp.text()).toBe('None');
-
- wrapperDropdownValue.destroy();
- });
+ expect(valueComp.exists()).toBe(true);
+ expect(valueComp.text()).toBe('None');
});
- it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => {
+ it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => {
+ createComponent();
wrapper.vm.$store.dispatch('toggleDropdownButton');
-
+ await wrapper.vm.$nextTick;
expect(wrapper.find(DropdownButton).exists()).toBe(true);
});
- it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', () => {
+ it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => {
+ createComponent();
wrapper.vm.$store.dispatch('toggleDropdownContents');
-
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.find(DropdownContents).exists()).toBe(true);
- });
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownContents).exists()).toBe(true);
});
describe('sets content direction based on viewport', () => {
- it('does not set direction when `state.variant` is not "embedded"', () => {
- wrapper.vm.$store.dispatch('toggleDropdownContents');
+ it('does not set direction when `state.variant` is not "embedded"', async () => {
+ createComponent();
+ wrapper.vm.$store.dispatch('toggleDropdownContents');
wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
+ await wrapper.vm.$nextTick;
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
- });
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
});
describe('when `state.variant` is "embedded"', () => {
beforeEach(() => {
- wrapper = createComponent({ ...mockConfig, variant: 'embedded' });
+ createComponent({ ...mockConfig, variant: 'embedded' });
wrapper.vm.$store.dispatch('toggleDropdownContents');
});
@@ -216,4 +226,22 @@ describe('LabelsSelectRoot', () => {
});
});
});
+
+ it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: true });
+
+ expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents');
+ });
+
+ it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: false });
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
});
diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js
index f58647ff12b..2822b1999bc 100644
--- a/spec/frontend/vue_shared/components/toggle_button_spec.js
+++ b/spec/frontend/vue_shared/components/toggle_button_spec.js
@@ -1,101 +1,96 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import toggleButton from '~/vue_shared/components/toggle_button.vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import ToggleButton from '~/vue_shared/components/toggle_button.vue';
-describe('Toggle Button', () => {
- let vm;
- let Component;
+describe('Toggle Button component', () => {
+ let wrapper;
- beforeEach(() => {
- Component = Vue.extend(toggleButton);
- });
+ function createComponent(propsData = {}) {
+ wrapper = shallowMount(ToggleButton, {
+ propsData,
+ });
+ }
+
+ const findInput = () => wrapper.find('input');
+ const findButton = () => wrapper.find('button');
+ const findToggleIcon = () => wrapper.find(GlIcon);
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ wrapper = null;
});
- describe('render output', () => {
- beforeEach(() => {
- vm = mountComponent(Component, {
- value: true,
- name: 'foo',
- });
- });
-
- it('renders input with provided name', () => {
- expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo');
+ it('renders input with provided name', () => {
+ createComponent({
+ name: 'foo',
});
- it('renders input with provided value', () => {
- expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true');
- });
-
- it('renders input status icon', () => {
- expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1);
- expect(vm.$el.querySelectorAll('svg.s18').length).toEqual(1);
- });
+ expect(findInput().attributes('name')).toBe('foo');
});
- describe('is-checked', () => {
+ describe.each`
+ value | iconName
+ ${true} | ${'status_success_borderless'}
+ ${false} | ${'status_failed_borderless'}
+ `('when `value` prop is `$value`', ({ value, iconName }) => {
beforeEach(() => {
- vm = mountComponent(Component, {
- value: true,
+ createComponent({
+ value,
+ name: 'foo',
});
-
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
});
- it('renders is checked class', () => {
- expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true);
+ it('renders input with correct value attribute', () => {
+ expect(findInput().attributes('value')).toBe(`${value}`);
});
- it('sets aria-label representing toggle state', () => {
- vm.value = true;
-
- expect(vm.ariaLabel).toEqual('Toggle Status: ON');
-
- vm.value = false;
-
- expect(vm.ariaLabel).toEqual('Toggle Status: OFF');
+ it('renders correct icon', () => {
+ const icon = findToggleIcon();
+ expect(icon.isVisible()).toBe(true);
+ expect(icon.props('name')).toBe(iconName);
+ expect(findButton().classes('is-checked')).toBe(value);
});
- it('emits change event when clicked', () => {
- vm.$el.querySelector('button').click();
+ describe('when clicked', () => {
+ it('emits `change` event with correct event', async () => {
+ findButton().trigger('click');
+ await wrapper.vm.$nextTick();
- expect(vm.$emit).toHaveBeenCalledWith('change', false);
+ expect(wrapper.emitted('change')).toStrictEqual([[!value]]);
+ });
});
});
- describe('is-disabled', () => {
+ describe('when `disabledInput` prop is `true`', () => {
beforeEach(() => {
- vm = mountComponent(Component, {
+ createComponent({
value: true,
disabledInput: true,
});
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
});
it('renders disabled button', () => {
- expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true);
+ expect(findButton().classes()).toContain('is-disabled');
});
- it('does not emit change event when clicked', () => {
- vm.$el.querySelector('button').click();
+ it('does not emit change event when clicked', async () => {
+ findButton().trigger('click');
+ await wrapper.vm.$nextTick();
- expect(vm.$emit).not.toHaveBeenCalled();
+ expect(wrapper.emitted('change')).toBeFalsy();
});
});
- describe('is-loading', () => {
+ describe('when `isLoading` prop is `true`', () => {
beforeEach(() => {
- vm = mountComponent(Component, {
+ createComponent({
value: true,
isLoading: true,
});
});
it('renders loading class', () => {
- expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true);
+ expect(findButton().classes()).toContain('is-loading');
});
});
});
diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
new file mode 100644
index 00000000000..175abf5aae0
--- /dev/null
+++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
@@ -0,0 +1,239 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+
+const DUMMY_TEXT = 'lorem-ipsum-dolar-sit-amit-consectur-adipiscing-elit-sed-do';
+
+const createChildElement = () => `<a href="#">${DUMMY_TEXT}</a>`;
+
+jest.mock('~/lib/utils/dom_utils', () => ({
+ hasHorizontalOverflow: jest.fn(() => {
+ throw new Error('this needs to be mocked');
+ }),
+}));
+
+describe('TooltipOnTruncate component', () => {
+ let wrapper;
+ let parent;
+
+ const createComponent = ({ propsData, ...options } = {}) => {
+ wrapper = shallowMount(TooltipOnTruncate, {
+ attachToDocument: true,
+ propsData: {
+ ...propsData,
+ },
+ ...options,
+ });
+ };
+
+ const createWrappedComponent = ({ propsData, ...options }) => {
+ // set a parent around the tested component
+ parent = mount(
+ {
+ props: {
+ title: { default: '' },
+ },
+ template: `
+ <TooltipOnTruncate :title="title" truncate-target="child">
+ <div>{{title}}</div>
+ </TooltipOnTruncate>
+ `,
+ components: {
+ TooltipOnTruncate,
+ },
+ },
+ {
+ propsData: { ...propsData },
+ attachToDocument: true,
+ ...options,
+ },
+ );
+
+ wrapper = parent.find(TooltipOnTruncate);
+ };
+
+ const hasTooltip = () => wrapper.classes('js-show-tooltip');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with default target', () => {
+ it('renders tooltip if truncated', () => {
+ hasHorizontalOverflow.mockReturnValueOnce(true);
+ createComponent({
+ propsData: {
+ title: DUMMY_TEXT,
+ },
+ slots: {
+ default: [DUMMY_TEXT],
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element);
+ expect(hasTooltip()).toBe(true);
+ expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT);
+ expect(wrapper.attributes('data-placement')).toEqual('top');
+ });
+ });
+
+ it('does not render tooltip if normal', () => {
+ hasHorizontalOverflow.mockReturnValueOnce(false);
+ createComponent({
+ propsData: {
+ title: DUMMY_TEXT,
+ },
+ slots: {
+ default: [DUMMY_TEXT],
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element);
+ expect(hasTooltip()).toBe(false);
+ });
+ });
+ });
+
+ describe('with child target', () => {
+ it('renders tooltip if truncated', () => {
+ hasHorizontalOverflow.mockReturnValueOnce(true);
+ createComponent({
+ propsData: {
+ title: DUMMY_TEXT,
+ truncateTarget: 'child',
+ },
+ slots: {
+ default: createChildElement(),
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]);
+ expect(hasTooltip()).toBe(true);
+ });
+ });
+
+ it('does not render tooltip if normal', () => {
+ hasHorizontalOverflow.mockReturnValueOnce(false);
+ createComponent({
+ propsData: {
+ truncateTarget: 'child',
+ },
+ slots: {
+ default: createChildElement(),
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]);
+ expect(hasTooltip()).toBe(false);
+ });
+ });
+ });
+
+ describe('with fn target', () => {
+ it('renders tooltip if truncated', () => {
+ hasHorizontalOverflow.mockReturnValueOnce(true);
+ createComponent({
+ propsData: {
+ title: DUMMY_TEXT,
+ truncateTarget: el => el.childNodes[1],
+ },
+ slots: {
+ default: [createChildElement(), createChildElement()],
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[1]);
+ expect(hasTooltip()).toBe(true);
+ });
+ });
+ });
+
+ describe('placement', () => {
+ it('sets data-placement when tooltip is rendered', () => {
+ const placement = 'bottom';
+
+ hasHorizontalOverflow.mockReturnValueOnce(true);
+ createComponent({
+ propsData: {
+ placement,
+ },
+ slots: {
+ default: DUMMY_TEXT,
+ },
+ });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(hasTooltip()).toBe(true);
+ expect(wrapper.attributes('data-placement')).toEqual(placement);
+ });
+ });
+ });
+
+ describe('updates when title and slot content changes', () => {
+ describe('is initialized with a long text', () => {
+ beforeEach(() => {
+ hasHorizontalOverflow.mockReturnValueOnce(true);
+ createWrappedComponent({
+ propsData: { title: DUMMY_TEXT },
+ });
+ return parent.vm.$nextTick();
+ });
+
+ it('renders tooltip', () => {
+ expect(hasTooltip()).toBe(true);
+ expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT);
+ expect(wrapper.attributes('data-placement')).toEqual('top');
+ });
+
+ it('does not render tooltip after updated to a short text', () => {
+ hasHorizontalOverflow.mockReturnValueOnce(false);
+ parent.setProps({
+ title: 'new-text',
+ });
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot
+ .then(() => {
+ expect(hasTooltip()).toBe(false);
+ });
+ });
+ });
+
+ describe('is initialized with a short text', () => {
+ beforeEach(() => {
+ hasHorizontalOverflow.mockReturnValueOnce(false);
+ createWrappedComponent({
+ propsData: { title: DUMMY_TEXT },
+ });
+ return wrapper.vm.$nextTick();
+ });
+
+ it('does not render tooltip', () => {
+ expect(hasTooltip()).toBe(false);
+ });
+
+ it('renders tooltip after text is updated', () => {
+ hasHorizontalOverflow.mockReturnValueOnce(true);
+ const newText = 'new-text';
+ parent.setProps({
+ title: newText,
+ });
+
+ return wrapper.vm
+ .$nextTick()
+ .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot
+ .then(() => {
+ expect(hasTooltip()).toBe(true);
+ expect(wrapper.attributes('data-original-title')).toEqual(newText);
+ expect(wrapper.attributes('data-placement')).toEqual('top');
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js
new file mode 100644
index 00000000000..7e70407655a
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js
@@ -0,0 +1,64 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
+
+describe('SecurityReportDownloadDropdown component', () => {
+ let wrapper;
+ let artifacts;
+
+ const createComponent = props => {
+ wrapper = shallowMount(SecurityReportDownloadDropdown, {
+ propsData: { ...props },
+ });
+ };
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('given report artifacts', () => {
+ beforeEach(() => {
+ artifacts = [
+ {
+ name: 'foo',
+ path: '/foo.json',
+ },
+ {
+ name: 'bar',
+ path: '/bar.json',
+ },
+ ];
+
+ createComponent({ artifacts });
+ });
+
+ it('renders a dropdown', () => {
+ expect(findDropdown().props('loading')).toBe(false);
+ });
+
+ it('renders a dropdown items for each artifact', () => {
+ artifacts.forEach((artifact, i) => {
+ const item = findDropdownItems().at(i);
+ expect(item.text()).toContain(artifact.name);
+ expect(item.attributes()).toMatchObject({
+ href: artifact.path,
+ download: expect.any(String),
+ });
+ });
+ });
+ });
+
+ describe('given it is loading', () => {
+ beforeEach(() => {
+ createComponent({ artifacts: [], loading: true });
+ });
+
+ it('renders a loading dropdown', () => {
+ expect(findDropdown().props('loading')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js
new file mode 100644
index 00000000000..e93ca8329e7
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/mock_data.js
@@ -0,0 +1,437 @@
+import {
+ REPORT_TYPE_SAST,
+ REPORT_TYPE_SECRET_DETECTION,
+} from '~/vue_shared/security_reports/constants';
+
+export const mockFindings = [
+ {
+ id: null,
+ report_type: 'dependency_scanning',
+ name: 'Cross-site Scripting in serialize-javascript',
+ severity: 'critical',
+ scanner: {
+ external_id: 'gemnasium',
+ name: 'Gemnasium',
+ version: '1.1.1',
+ url: 'https://gitlab.com/gitlab-org/security-products/gemnasium',
+ },
+ identifiers: [
+ {
+ external_type: 'gemnasium',
+ external_id: '58caa017-9a9a-46d6-bab2-ec930f46833c',
+ name: 'Gemnasium-58caa017-9a9a-46d6-bab2-ec930f46833c',
+ url:
+ 'https://deps.sec.gitlab.com/packages/npm/serialize-javascript/versions/1.7.0/advisories',
+ },
+ {
+ external_type: 'cve',
+ external_id: 'CVE-2019-16769',
+ name: 'CVE-2019-16769',
+ url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-16769',
+ },
+ ],
+ project_fingerprint: '09df9f4d11c8deb93d81bdcc39f7667b44143298',
+ create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ create_vulnerability_feedback_merge_request_path:
+ '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ project: {
+ id: 7071551,
+ name: 'gitlab-ui',
+ full_path: '/gitlab-org/gitlab-ui',
+ full_name: 'GitLab.org / gitlab-ui',
+ },
+ dismissal_feedback: null,
+ issue_feedback: null,
+ merge_request_feedback: null,
+ description:
+ 'The serialize-javascript npm package is vulnerable to Cross-site Scripting (XSS). It does not properly mitigate against unsafe characters in serialized regular expressions. If serialized data of regular expression objects are used in an environment other than Node.js, it is affected by this vulnerability.',
+ links: [{ url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-16769' }],
+ location: {
+ file: 'yarn.lock',
+ dependency: { package: { name: 'serialize-javascript' }, version: '1.7.0' },
+ },
+ remediations: [null],
+ solution: 'Upgrade to version 2.1.1 or above.',
+ state: 'opened',
+ blob_path: '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/yarn.lock',
+ evidence: 'Credit Card Detected: Diners Card',
+ },
+ {
+ id: null,
+ report_type: 'dependency_scanning',
+ name: '3rd party CORS request may execute in jquery',
+ severity: 'high',
+ scanner: { external_id: 'retire.js', name: 'Retire.js' },
+ identifiers: [
+ {
+ external_type: 'cve',
+ external_id: 'CVE-2015-9251',
+ name: 'CVE-2015-9251',
+ url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2015-9251',
+ },
+ ],
+ project_fingerprint: '1ecd3b214cf39c0b9ad23a0a9679778d7cf55876',
+ create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ create_vulnerability_feedback_merge_request_path:
+ '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ project: {
+ id: 7071551,
+ name: 'gitlab-ui',
+ full_path: '/gitlab-org/gitlab-ui',
+ full_name: 'GitLab.org / gitlab-ui',
+ },
+ dismissal_feedback: {
+ id: 2528,
+ created_at: '2019-08-26T12:30:32.349Z',
+ project_id: 7071551,
+ author: {
+ id: 181229,
+ name: "Lukas 'Eipi' Eipert",
+ username: 'leipert',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/leipert',
+ status_tooltip_html: null,
+ path: '/leipert',
+ },
+ comment_details: {
+ comment: 'This particular jQuery version appears in a test path of tinycolor2.\n',
+ comment_timestamp: '2019-08-26T12:30:37.610Z',
+ comment_author: {
+ id: 181229,
+ name: "Lukas 'Eipi' Eipert",
+ username: 'leipert',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/leipert',
+ status_tooltip_html: null,
+ path: '/leipert',
+ },
+ },
+ pipeline: { id: 78375355, path: '/gitlab-org/gitlab-ui/pipelines/78375355' },
+ destroy_vulnerability_feedback_dismissal_path:
+ '/gitlab-org/gitlab-ui/vulnerability_feedback/2528',
+ category: 'dependency_scanning',
+ feedback_type: 'dismissal',
+ branch: 'leipert-dogfood-secure',
+ project_fingerprint: '1ecd3b214cf39c0b9ad23a0a9679778d7cf55876',
+ },
+ issue_feedback: null,
+ merge_request_feedback: null,
+ description: null,
+ links: [
+ { url: 'https://github.com/jquery/jquery/issues/2432' },
+ { url: 'http://blog.jquery.com/2016/01/08/jquery-2-2-and-1-12-released/' },
+ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2015-9251' },
+ { url: 'http://research.insecurelabs.org/jquery/test/' },
+ ],
+ location: {
+ file: 'node_modules/tinycolor2/demo/jquery-1.9.1.js',
+ dependency: { package: { name: 'jquery' }, version: '1.9.1' },
+ },
+ remediations: [null],
+ solution: null,
+ state: 'dismissed',
+ blob_path:
+ '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/node_modules/tinycolor2/demo/jquery-1.9.1.js',
+ },
+ {
+ id: null,
+ report_type: 'dependency_scanning',
+ name:
+ 'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery',
+ severity: 'low',
+ scanner: { external_id: 'retire.js', name: 'Retire.js' },
+ identifiers: [
+ {
+ external_type: 'cve',
+ external_id: 'CVE-2019-11358',
+ name: 'CVE-2019-11358',
+ url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-11358',
+ },
+ ],
+ project_fingerprint: 'aeb4b2442d92d0ccf7023f0c220bda8b4ba910e3',
+ create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ create_vulnerability_feedback_merge_request_path:
+ '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ project: {
+ id: 7071551,
+ name: 'gitlab-ui',
+ full_path: '/gitlab-org/gitlab-ui',
+ full_name: 'GitLab.org / gitlab-ui',
+ },
+ dismissal_feedback: {
+ id: 4197,
+ created_at: '2019-11-14T11:03:18.472Z',
+ project_id: 7071551,
+ author: {
+ id: 181229,
+ name: "Lukas 'Eipi' Eipert",
+ username: 'leipert',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/leipert',
+ status_tooltip_html: null,
+ path: '/leipert',
+ },
+ comment_details: {
+ comment:
+ 'This is a false positive, as it just part of some documentation assets of sass-true.',
+ comment_timestamp: '2019-11-14T11:03:18.464Z',
+ comment_author: {
+ id: 181229,
+ name: "Lukas 'Eipi' Eipert",
+ username: 'leipert',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/leipert',
+ status_tooltip_html: null,
+ path: '/leipert',
+ },
+ },
+ destroy_vulnerability_feedback_dismissal_path:
+ '/gitlab-org/gitlab-ui/vulnerability_feedback/4197',
+ category: 'dependency_scanning',
+ feedback_type: 'dismissal',
+ branch: null,
+ project_fingerprint: 'aeb4b2442d92d0ccf7023f0c220bda8b4ba910e3',
+ },
+ issue_feedback: null,
+ merge_request_feedback: null,
+ description: null,
+ links: [
+ { url: 'https://blog.jquery.com/2019/04/10/jquery-3-4-0-released/' },
+ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-11358' },
+ { url: 'https://github.com/jquery/jquery/commit/753d591aea698e57d6db58c9f722cd0808619b1b' },
+ ],
+ location: {
+ file: 'node_modules/sass-true/docs/assets/webpack/common.min.js',
+ dependency: { package: { name: 'jquery' }, version: '3.3.1' },
+ },
+ remediations: [null],
+ solution: null,
+ state: 'dismissed',
+ blob_path:
+ '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/node_modules/sass-true/docs/assets/webpack/common.min.js',
+ },
+ {
+ id: null,
+ report_type: 'dependency_scanning',
+ name:
+ 'jQuery before 3.4.0, as used in Drupal, Backdrop CMS, and other products, mishandles jQuery.extend(true, {}, ...) because of Object.prototype pollution in jquery',
+ severity: 'low',
+ scanner: { external_id: 'retire.js', name: 'Retire.js' },
+ identifiers: [
+ {
+ external_type: 'cve',
+ external_id: 'CVE-2019-11358',
+ name: 'CVE-2019-11358',
+ url: 'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-11358',
+ },
+ ],
+ project_fingerprint: 'eb86aa13eb9d897a083ead6e134aa78aa9cadd52',
+ create_vulnerability_feedback_issue_path: '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ create_vulnerability_feedback_merge_request_path:
+ '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ create_vulnerability_feedback_dismissal_path: '/gitlab-org/gitlab-ui/vulnerability_feedback',
+ project: {
+ id: 7071551,
+ name: 'gitlab-ui',
+ full_path: '/gitlab-org/gitlab-ui',
+ full_name: 'GitLab.org / gitlab-ui',
+ },
+ dismissal_feedback: {
+ id: 2527,
+ created_at: '2019-08-26T12:29:43.624Z',
+ project_id: 7071551,
+ author: {
+ id: 181229,
+ name: "Lukas 'Eipi' Eipert",
+ username: 'leipert',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/leipert',
+ status_tooltip_html: null,
+ path: '/leipert',
+ },
+ comment_details: {
+ comment: 'This particular jQuery version appears in a test path of tinycolor2.',
+ comment_timestamp: '2019-08-26T12:30:14.840Z',
+ comment_author: {
+ id: 181229,
+ name: "Lukas 'Eipi' Eipert",
+ username: 'leipert',
+ state: 'active',
+ avatar_url:
+ 'https://secure.gravatar.com/avatar/19a1f1260fa70323f35bc508927921a2?s=80\u0026d=identicon',
+ web_url: 'https://gitlab.com/leipert',
+ status_tooltip_html: null,
+ path: '/leipert',
+ },
+ },
+ pipeline: { id: 78375355, path: '/gitlab-org/gitlab-ui/pipelines/78375355' },
+ destroy_vulnerability_feedback_dismissal_path:
+ '/gitlab-org/gitlab-ui/vulnerability_feedback/2527',
+ category: 'dependency_scanning',
+ feedback_type: 'dismissal',
+ branch: 'leipert-dogfood-secure',
+ project_fingerprint: 'eb86aa13eb9d897a083ead6e134aa78aa9cadd52',
+ },
+ issue_feedback: null,
+ merge_request_feedback: null,
+ description: null,
+ links: [
+ { url: 'https://blog.jquery.com/2019/04/10/jquery-3-4-0-released/' },
+ { url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-11358' },
+ { url: 'https://github.com/jquery/jquery/commit/753d591aea698e57d6db58c9f722cd0808619b1b' },
+ ],
+ location: {
+ file: 'node_modules/tinycolor2/demo/jquery-1.9.1.js',
+ dependency: { package: { name: 'jquery' }, version: '1.9.1' },
+ },
+ remediations: [null],
+ solution: null,
+ state: 'dismissed',
+ blob_path:
+ '/gitlab-org/gitlab-ui/blob/ad137f0a8ac59af961afe47d04e5cc062c6864a9/node_modules/tinycolor2/demo/jquery-1.9.1.js',
+ },
+];
+
+export const sastDiffSuccessMock = {
+ added: [mockFindings[0]],
+ fixed: [mockFindings[1], mockFindings[2]],
+ existing: [mockFindings[3]],
+ base_report_created_at: '2020-01-01T10:00:00.000Z',
+ base_report_out_of_date: false,
+ head_report_created_at: '2020-01-10T10:00:00.000Z',
+};
+
+export const secretScanningDiffSuccessMock = {
+ added: [mockFindings[0], mockFindings[1]],
+ fixed: [mockFindings[2]],
+ base_report_created_at: '2020-01-01T10:00:00.000Z',
+ base_report_out_of_date: false,
+ head_report_created_at: '2020-01-10T10:00:00.000Z',
+};
+
+export const securityReportDownloadPathsQueryResponse = {
+ project: {
+ mergeRequest: {
+ headPipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/176',
+ jobs: {
+ nodes: [
+ {
+ name: 'secret_detection',
+ artifacts: {
+ nodes: [
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection',
+ fileType: 'SECRET_DETECTION',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ __typename: 'CiJob',
+ },
+ {
+ name: 'bandit-sast',
+ artifacts: {
+ nodes: [
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast',
+ fileType: 'SAST',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ __typename: 'CiJob',
+ },
+ {
+ name: 'eslint-sast',
+ artifacts: {
+ nodes: [
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast',
+ fileType: 'SAST',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ __typename: 'CiJob',
+ },
+ ],
+ __typename: 'CiJobConnection',
+ },
+ __typename: 'Pipeline',
+ },
+ __typename: 'MergeRequest',
+ },
+ __typename: 'Project',
+ },
+};
+
+/**
+ * These correspond to SAST jobs in the securityReportDownloadPathsQueryResponse above.
+ */
+export const sastArtifacts = [
+ {
+ name: 'bandit-sast',
+ reportType: REPORT_TYPE_SAST,
+ path: '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast',
+ },
+ {
+ name: 'eslint-sast',
+ reportType: REPORT_TYPE_SAST,
+ path: '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast',
+ },
+];
+
+/**
+ * These correspond to Secret Detection jobs in the securityReportDownloadPathsQueryResponse above.
+ */
+export const secretDetectionArtifacts = [
+ {
+ name: 'secret_detection',
+ reportType: REPORT_TYPE_SECRET_DETECTION,
+ path:
+ '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection',
+ },
+];
+
+export const expectedDownloadDropdownProps = {
+ loading: false,
+ artifacts: [...secretDetectionArtifacts, ...sastArtifacts],
+};
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 ab87d80b291..c440081a0c4 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
@@ -1,162 +1,465 @@
-import { mount } from '@vue/test-utils';
+import { mount, createLocalVue } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { merge } from 'lodash';
+import VueApollo from 'vue-apollo';
+import Vuex from 'vuex';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import { trimText } from 'helpers/text_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import {
+ expectedDownloadDropdownProps,
+ securityReportDownloadPathsQueryResponse,
+ sastDiffSuccessMock,
+ secretScanningDiffSuccessMock,
+} from 'jest/vue_shared/security_reports/mock_data';
import Api from '~/api';
-import Flash from '~/flash';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import {
+ REPORT_TYPE_SAST,
+ REPORT_TYPE_SECRET_DETECTION,
+} from '~/vue_shared/security_reports/constants';
+import HelpIcon from '~/vue_shared/security_reports/components/help_icon.vue';
+import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
+import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql';
jest.mock('~/flash');
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const SAST_COMPARISON_PATH = '/sast.json';
+const SECRET_SCANNING_COMPARISON_PATH = '/secret_detection.json';
+
describe('Security reports app', () => {
let wrapper;
- let mrTabsMock;
const props = {
pipelineId: 123,
projectId: 456,
securityReportsDocsPath: '/docs',
+ discoverProjectSecurityPath: '/discoverProjectSecurityPath',
};
- const createComponent = () => {
- wrapper = mount(SecurityReportsApp, {
- propsData: { ...props },
- });
+ const createComponent = options => {
+ wrapper = mount(
+ SecurityReportsApp,
+ merge(
+ {
+ localVue,
+ propsData: { ...props },
+ stubs: {
+ HelpIcon: true,
+ },
+ },
+ options,
+ ),
+ );
+ };
+
+ const pendingHandler = () => new Promise(() => {});
+ const successHandler = () => Promise.resolve({ data: securityReportDownloadPathsQueryResponse });
+ const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] });
+ const createMockApolloProvider = handler => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [[securityReportDownloadPathsQuery, handler]];
+
+ return createMockApollo(requestHandlers);
};
const anyParams = expect.any(Object);
+ const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown);
const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
- const findHelpLink = () => wrapper.find('[data-testid="help"]');
- const setupMrTabsMock = () => {
- mrTabsMock = { tabShown: jest.fn() };
- window.mrTabs = mrTabsMock;
- };
+ const findHelpIconComponent = () => wrapper.find(HelpIcon);
const setupMockJobArtifact = reportType => {
jest
.spyOn(Api, 'pipelineJobs')
.mockResolvedValue({ data: [{ artifacts: [{ file_type: reportType }] }] });
};
+ const expectPipelinesTabAnchor = () => {
+ const mrTabsMock = { tabShown: jest.fn() };
+ window.mrTabs = mrTabsMock;
+ findPipelinesTabAnchor().trigger('click');
+ expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]);
+ };
afterEach(() => {
wrapper.destroy();
delete window.mrTabs;
});
- describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => {
- beforeEach(() => {
- window.mrTabs = { tabShown: jest.fn() };
- setupMockJobArtifact(reportType);
- createComponent();
- return wrapper.vm.$nextTick();
- });
+ describe.each([false, true])(
+ 'given the coreSecurityMrWidgetCounts feature flag is %p',
+ coreSecurityMrWidgetCounts => {
+ const createComponentWithFlag = options =>
+ createComponent(
+ merge(
+ {
+ provide: {
+ glFeatures: {
+ coreSecurityMrWidgetCounts,
+ },
+ },
+ },
+ options,
+ ),
+ );
- it('calls the pipelineJobs API correctly', () => {
- expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
- expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
- });
+ describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => {
+ beforeEach(() => {
+ window.mrTabs = { tabShown: jest.fn() };
+ setupMockJobArtifact(reportType);
+ createComponentWithFlag();
+ return wrapper.vm.$nextTick();
+ });
- it('renders the expected message', () => {
- expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
- });
+ it('calls the pipelineJobs API correctly', () => {
+ expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
+ expect(Api.pipelineJobs).toHaveBeenCalledWith(
+ props.projectId,
+ props.pipelineId,
+ anyParams,
+ );
+ });
- describe('clicking the anchor to the pipelines tab', () => {
- beforeEach(() => {
- setupMrTabsMock();
- findPipelinesTabAnchor().trigger('click');
+ it('renders the expected message', () => {
+ expect(wrapper.text()).toMatchInterpolatedText(
+ SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance,
+ );
+ });
+
+ describe('clicking the anchor to the pipelines tab', () => {
+ it('calls the mrTabs.tabShown global', () => {
+ expectPipelinesTabAnchor();
+ });
+ });
+
+ it('renders a help link', () => {
+ expect(findHelpIconComponent().props()).toEqual({
+ helpPath: props.securityReportsDocsPath,
+ discoverProjectSecurityPath: props.discoverProjectSecurityPath,
+ });
+ });
+ });
+
+ describe('given a report type "foo"', () => {
+ beforeEach(() => {
+ setupMockJobArtifact('foo');
+ createComponentWithFlag();
+ return wrapper.vm.$nextTick();
+ });
+
+ it('calls the pipelineJobs API correctly', () => {
+ expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
+ expect(Api.pipelineJobs).toHaveBeenCalledWith(
+ props.projectId,
+ props.pipelineId,
+ anyParams,
+ );
+ });
+
+ it('renders nothing', () => {
+ expect(wrapper.html()).toBe('');
+ });
});
- it('calls the mrTabs.tabShown global', () => {
- expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]);
+ 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');
+ });
+
+ createComponentWithFlag();
+ return wrapper.vm.$nextTick();
+ });
+
+ it('fetches all pages', () => {
+ expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages);
+ });
+
+ it('renders the expected message', () => {
+ expect(wrapper.text()).toMatchInterpolatedText(
+ SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance,
+ );
+ });
});
- });
- it('renders a help link', () => {
- expect(findHelpLink().attributes()).toMatchObject({
- href: props.securityReportsDocsPath,
+ describe('given an error from the API', () => {
+ let error;
+
+ beforeEach(() => {
+ error = new Error('an error');
+ jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error);
+ createComponentWithFlag();
+ return wrapper.vm.$nextTick();
+ });
+
+ it('calls the pipelineJobs API correctly', () => {
+ expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
+ expect(Api.pipelineJobs).toHaveBeenCalledWith(
+ props.projectId,
+ props.pipelineId,
+ anyParams,
+ );
+ });
+
+ it('renders nothing', () => {
+ expect(wrapper.html()).toBe('');
+ });
+
+ it('calls createFlash correctly', () => {
+ expect(createFlash.mock.calls).toEqual([
+ [
+ {
+ message: SecurityReportsApp.i18n.apiError,
+ captureError: true,
+ error,
+ },
+ ],
+ ]);
+ });
});
- });
- });
+ },
+ );
+
+ describe('given the coreSecurityMrWidgetCounts feature flag is enabled', () => {
+ let mock;
+
+ const createComponentWithFlagEnabled = options =>
+ createComponent(
+ merge(options, {
+ provide: {
+ glFeatures: {
+ coreSecurityMrWidgetCounts: true,
+ },
+ },
+ }),
+ );
- describe('given a report type "foo"', () => {
beforeEach(() => {
- setupMockJobArtifact('foo');
- createComponent();
- return wrapper.vm.$nextTick();
+ mock = new MockAdapter(axios);
});
- it('calls the pipelineJobs API correctly', () => {
- expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
- expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
+ afterEach(() => {
+ mock.restore();
});
- it('renders nothing', () => {
- expect(wrapper.html()).toBe('');
- });
+ const SAST_SUCCESS_MESSAGE =
+ 'Security scanning detected 1 potential vulnerability 1 Critical 0 High and 0 Others';
+ const SECRET_SCANNING_SUCCESS_MESSAGE =
+ 'Security scanning detected 2 potential vulnerabilities 1 Critical 1 High and 0 Others';
+ describe.each`
+ reportType | pathProp | path | successResponse | successMessage
+ ${REPORT_TYPE_SAST} | ${'sastComparisonPath'} | ${SAST_COMPARISON_PATH} | ${sastDiffSuccessMock} | ${SAST_SUCCESS_MESSAGE}
+ ${REPORT_TYPE_SECRET_DETECTION} | ${'secretScanningComparisonPath'} | ${SECRET_SCANNING_COMPARISON_PATH} | ${secretScanningDiffSuccessMock} | ${SECRET_SCANNING_SUCCESS_MESSAGE}
+ `(
+ 'given a $pathProp and $reportType artifact',
+ ({ reportType, pathProp, path, successResponse, successMessage }) => {
+ beforeEach(() => {
+ setupMockJobArtifact(reportType);
+ });
+
+ describe('when loading', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios, { delayResponse: 1 });
+ mock.onGet(path).replyOnce(200, successResponse);
+
+ createComponentWithFlagEnabled({
+ propsData: {
+ [pathProp]: path,
+ },
+ });
+
+ return waitForPromises();
+ });
+
+ it('should have loading message', () => {
+ expect(wrapper.text()).toBe('Security scanning is loading');
+ });
+
+ it('should not render the pipeline tab anchor', () => {
+ expect(findPipelinesTabAnchor().exists()).toBe(false);
+ });
+ });
+
+ describe('when successfully loaded', () => {
+ beforeEach(() => {
+ mock.onGet(path).replyOnce(200, successResponse);
+
+ createComponentWithFlagEnabled({
+ propsData: {
+ [pathProp]: path,
+ },
+ });
+
+ return waitForPromises();
+ });
+
+ it('should show counts', () => {
+ expect(trimText(wrapper.text())).toContain(successMessage);
+ });
+
+ it('should render the pipeline tab anchor', () => {
+ expectPipelinesTabAnchor();
+ });
+ });
+
+ describe('when an error occurs', () => {
+ beforeEach(() => {
+ mock.onGet(path).replyOnce(500);
+
+ createComponentWithFlagEnabled({
+ propsData: {
+ [pathProp]: path,
+ },
+ });
+
+ return waitForPromises();
+ });
+
+ it('should show error message', () => {
+ expect(trimText(wrapper.text())).toContain('Loading resulted in an error');
+ });
+
+ it('should render the pipeline tab anchor', () => {
+ expectPipelinesTabAnchor();
+ });
+ });
+ },
+ );
});
- describe('security artifacts on last page of multi-page response', () => {
- const numPages = 3;
+ describe('given coreSecurityMrWidgetDownloads feature flag is enabled', () => {
+ const createComponentWithFlagEnabled = options =>
+ createComponent(
+ merge(options, {
+ provide: {
+ glFeatures: {
+ coreSecurityMrWidgetDownloads: true,
+ },
+ },
+ }),
+ );
- 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();
+ describe('given the query is loading', () => {
+ beforeEach(() => {
+ createComponentWithFlagEnabled({
+ apolloProvider: createMockApolloProvider(pendingHandler),
+ });
+ });
+
+ // TODO: Remove this assertion as part of
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/273431
+ it('initially renders nothing', () => {
+ expect(wrapper.isEmpty()).toBe(true);
+ });
});
- it('fetches all pages', () => {
- expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages);
+ describe('given the query loads successfully', () => {
+ beforeEach(() => {
+ createComponentWithFlagEnabled({
+ apolloProvider: createMockApolloProvider(successHandler),
+ });
+ });
+
+ it('renders the download dropdown', () => {
+ expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps);
+ });
+
+ it('renders the expected message', () => {
+ const text = wrapper.text();
+ expect(text).not.toContain(SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance);
+ expect(text).toContain(SecurityReportsApp.i18n.scansHaveRun);
+ });
+
+ it('should not render the pipeline tab anchor', () => {
+ expect(findPipelinesTabAnchor().exists()).toBe(false);
+ });
});
- it('renders the expected message', () => {
- expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
+ describe('given the query fails', () => {
+ beforeEach(() => {
+ createComponentWithFlagEnabled({
+ apolloProvider: createMockApolloProvider(failureHandler),
+ });
+ });
+
+ it('calls createFlash correctly', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: SecurityReportsApp.i18n.apiError,
+ captureError: true,
+ error: expect.any(Error),
+ });
+ });
+
+ // TODO: Remove this assertion as part of
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/273431
+ it('renders nothing', () => {
+ expect(wrapper.isEmpty()).toBe(true);
+ });
});
});
- describe('given an error from the API', () => {
- let error;
+ describe('given coreSecurityMrWidgetCounts and coreSecurityMrWidgetDownloads feature flags are enabled', () => {
+ let mock;
beforeEach(() => {
- error = new Error('an error');
- jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error);
- createComponent();
- return wrapper.vm.$nextTick();
+ mock = new MockAdapter(axios);
+ mock.onGet(SAST_COMPARISON_PATH).replyOnce(200, sastDiffSuccessMock);
+ mock.onGet(SECRET_SCANNING_COMPARISON_PATH).replyOnce(200, secretScanningDiffSuccessMock);
+ createComponent({
+ propsData: {
+ sastComparisonPath: SAST_COMPARISON_PATH,
+ secretScanningComparisonPath: SECRET_SCANNING_COMPARISON_PATH,
+ },
+ provide: {
+ glFeatures: {
+ coreSecurityMrWidgetCounts: true,
+ coreSecurityMrWidgetDownloads: true,
+ },
+ },
+ apolloProvider: createMockApolloProvider(successHandler),
+ });
+
+ return waitForPromises();
});
- it('calls the pipelineJobs API correctly', () => {
- expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
- expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
+ afterEach(() => {
+ mock.restore();
});
- it('renders nothing', () => {
- expect(wrapper.html()).toBe('');
+ it('renders the download dropdown', () => {
+ expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps);
});
- it('calls Flash correctly', () => {
- expect(Flash.mock.calls).toEqual([
- [
- {
- message: SecurityReportsApp.i18n.apiError,
- captureError: true,
- error,
- },
- ],
- ]);
+ it('renders the expected counts message', () => {
+ expect(trimText(wrapper.text())).toContain(
+ 'Security scanning detected 3 potential vulnerabilities 2 Critical 1 High and 0 Others',
+ );
+ });
+
+ it('should not render the pipeline tab anchor', () => {
+ expect(findPipelinesTabAnchor().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/vue_shared/security_reports/store/getters_spec.js b/spec/frontend/vue_shared/security_reports/store/getters_spec.js
new file mode 100644
index 00000000000..8de704be455
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/store/getters_spec.js
@@ -0,0 +1,182 @@
+import createState from '~/vue_shared/security_reports/store/state';
+import createSastState from '~/vue_shared/security_reports/store/modules/sast/state';
+import createSecretScanningState from '~/vue_shared/security_reports/store/modules/secret_detection/state';
+import { groupedTextBuilder } from '~/vue_shared/security_reports/store/utils';
+import {
+ groupedSummaryText,
+ allReportsHaveError,
+ areReportsLoading,
+ anyReportHasError,
+ areAllReportsLoading,
+ anyReportHasIssues,
+ summaryCounts,
+} from '~/vue_shared/security_reports/store/getters';
+import { CRITICAL, HIGH, LOW } from '~/vulnerabilities/constants';
+
+const generateVuln = severity => ({ severity });
+
+describe('Security reports getters', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState();
+ state.sast = createSastState();
+ state.secretDetection = createSecretScanningState();
+ });
+
+ describe('summaryCounts', () => {
+ it('returns 0 count for empty state', () => {
+ expect(summaryCounts(state)).toEqual({
+ critical: 0,
+ high: 0,
+ other: 0,
+ });
+ });
+
+ describe('combines all reports', () => {
+ it('of the same severity', () => {
+ state.sast.newIssues = [generateVuln(CRITICAL)];
+ state.secretDetection.newIssues = [generateVuln(CRITICAL)];
+
+ expect(summaryCounts(state)).toEqual({
+ critical: 2,
+ high: 0,
+ other: 0,
+ });
+ });
+
+ it('of different severities', () => {
+ state.sast.newIssues = [generateVuln(CRITICAL)];
+ state.secretDetection.newIssues = [generateVuln(HIGH), generateVuln(LOW)];
+
+ expect(summaryCounts(state)).toEqual({
+ critical: 1,
+ high: 1,
+ other: 1,
+ });
+ });
+ });
+ });
+
+ describe('groupedSummaryText', () => {
+ it('returns failed text', () => {
+ expect(
+ groupedSummaryText(state, {
+ allReportsHaveError: true,
+ areReportsLoading: false,
+ summaryCounts: {},
+ }),
+ ).toEqual({ message: 'Security scanning failed loading any results' });
+ });
+
+ it('returns `is loading` as status text', () => {
+ expect(
+ groupedSummaryText(state, {
+ allReportsHaveError: false,
+ areReportsLoading: true,
+ summaryCounts: {},
+ }),
+ ).toEqual(
+ groupedTextBuilder({
+ reportType: 'Security scanning',
+ critical: 0,
+ high: 0,
+ other: 0,
+ status: 'is loading',
+ }),
+ );
+ });
+
+ it('returns no new status text if there are existing ones', () => {
+ expect(
+ groupedSummaryText(state, {
+ allReportsHaveError: false,
+ areReportsLoading: false,
+ summaryCounts: {},
+ }),
+ ).toEqual(
+ groupedTextBuilder({
+ reportType: 'Security scanning',
+ critical: 0,
+ high: 0,
+ other: 0,
+ status: '',
+ }),
+ );
+ });
+ });
+
+ describe('areReportsLoading', () => {
+ it('returns true when any report is loading', () => {
+ state.sast.isLoading = true;
+
+ expect(areReportsLoading(state)).toEqual(true);
+ });
+
+ it('returns false when none of the reports are loading', () => {
+ expect(areReportsLoading(state)).toEqual(false);
+ });
+ });
+
+ describe('areAllReportsLoading', () => {
+ it('returns true when all reports are loading', () => {
+ state.sast.isLoading = true;
+ state.secretDetection.isLoading = true;
+
+ expect(areAllReportsLoading(state)).toEqual(true);
+ });
+
+ it('returns false when some of the reports are loading', () => {
+ state.sast.isLoading = true;
+
+ expect(areAllReportsLoading(state)).toEqual(false);
+ });
+
+ it('returns false when none of the reports are loading', () => {
+ expect(areAllReportsLoading(state)).toEqual(false);
+ });
+ });
+
+ describe('allReportsHaveError', () => {
+ it('returns true when all reports have error', () => {
+ state.sast.hasError = true;
+ state.secretDetection.hasError = true;
+
+ expect(allReportsHaveError(state)).toEqual(true);
+ });
+
+ it('returns false when none of the reports have error', () => {
+ expect(allReportsHaveError(state)).toEqual(false);
+ });
+
+ it('returns false when one of the reports does not have error', () => {
+ state.secretDetection.hasError = true;
+
+ expect(allReportsHaveError(state)).toEqual(false);
+ });
+ });
+
+ describe('anyReportHasError', () => {
+ it('returns true when any of the reports has error', () => {
+ state.sast.hasError = true;
+
+ expect(anyReportHasError(state)).toEqual(true);
+ });
+
+ it('returns false when none of the reports has error', () => {
+ expect(anyReportHasError(state)).toEqual(false);
+ });
+ });
+
+ describe('anyReportHasIssues', () => {
+ it('returns true when any of the reports has new issues', () => {
+ state.sast.newIssues.push(generateVuln(LOW));
+
+ expect(anyReportHasIssues(state)).toEqual(true);
+ });
+
+ it('returns false when none of the reports has error', () => {
+ expect(anyReportHasIssues(state)).toEqual(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/security_reports/utils_spec.js b/spec/frontend/vue_shared/security_reports/utils_spec.js
new file mode 100644
index 00000000000..ea54644796a
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/utils_spec.js
@@ -0,0 +1,28 @@
+import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/utils';
+import {
+ REPORT_TYPE_SAST,
+ REPORT_TYPE_SECRET_DETECTION,
+} from '~/vue_shared/security_reports/constants';
+import {
+ securityReportDownloadPathsQueryResponse,
+ sastArtifacts,
+ secretDetectionArtifacts,
+} from './mock_data';
+
+describe('extractSecurityReportArtifacts', () => {
+ it.each`
+ reportTypes | expectedArtifacts
+ ${[]} | ${[]}
+ ${['foo']} | ${[]}
+ ${[REPORT_TYPE_SAST]} | ${sastArtifacts}
+ ${[REPORT_TYPE_SECRET_DETECTION]} | ${secretDetectionArtifacts}
+ ${[REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION]} | ${[...secretDetectionArtifacts, ...sastArtifacts]}
+ `(
+ 'returns the expected artifacts given report types $reportTypes',
+ ({ reportTypes, expectedArtifacts }) => {
+ expect(
+ extractSecurityReportArtifacts(reportTypes, securityReportDownloadPathsQueryResponse),
+ ).toEqual(expectedArtifacts);
+ },
+ );
+});