Commit eb95100c authored by mfluharty's avatar mfluharty

Make corrections to address review feedback

Refactor tests to follow conventions
Add XSS test
Eliminate a few unnecessary lines, comments, and parameters
Use Vue.set for nested state changes
parent 06b88af0
...@@ -172,8 +172,8 @@ export const splitCamelCase = string => ...@@ -172,8 +172,8 @@ export const splitCamelCase = string =>
* @param {String} string A string namespace, * @param {String} string A string namespace,
* i.e. "My Group / My Subgroup / My Project" * i.e. "My Group / My Subgroup / My Project"
*/ */
export const truncateNamespace = string => { export const truncateNamespace = (string = '') => {
if (_.isUndefined(string) || _.isNull(string) || !_.isString(string)) { if (_.isNull(string) || !_.isString(string)) {
return ''; return '';
} }
......
...@@ -56,8 +56,8 @@ export default { ...@@ -56,8 +56,8 @@ export default {
focusSearchInput() { focusSearchInput() {
this.$refs.searchInput.focus(); this.$refs.searchInput.focus();
}, },
onInput: _.debounce(function debouncedOnInput(e) { onInput: _.debounce(function debouncedOnInput() {
this.$emit('searched', e.target.value); this.$emit('searched', this.searchQuery);
}, SEARCH_INPUT_TIMEOUT_MS), }, SEARCH_INPUT_TIMEOUT_MS),
}, },
}; };
......
...@@ -17,9 +17,6 @@ ...@@ -17,9 +17,6 @@
margin-left: -$gl-padding; margin-left: -$gl-padding;
margin-right: -$gl-padding; margin-right: -$gl-padding;
// should be replaced by Bootstrap's
// .overflow-hidden utility class once
// we upgrade Bootstrap to at least 4.2.x
.project-namespace-name-container { .project-namespace-name-container {
overflow: hidden; overflow: hidden;
} }
......
import Vue from 'vue'; import Vue from 'vue';
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { shallowMount } from '@vue/test-utils';
import { trimText } from 'spec/helpers/vue_component_helper'; import { trimText } from 'spec/helpers/vue_component_helper';
import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here
const createComponent = () => { const createComponent = () => {
const Component = Vue.extend(frequentItemsListItemComponent); const Component = Vue.extend(frequentItemsListItemComponent);
return mountComponent(Component, { return shallowMount(Component, {
itemId: mockProject.id, propsData: {
itemName: mockProject.name, itemId: mockProject.id,
namespace: mockProject.namespace, itemName: mockProject.name,
webUrl: mockProject.webUrl, namespace: mockProject.namespace,
avatarUrl: mockProject.avatarUrl, webUrl: mockProject.webUrl,
avatarUrl: mockProject.avatarUrl,
},
}); });
}; };
describe('FrequentItemsListItemComponent', () => { describe('FrequentItemsListItemComponent', () => {
let wrapper;
let vm; let vm;
beforeEach(() => { beforeEach(() => {
vm = createComponent(); wrapper = createComponent();
({ vm } = wrapper);
}); });
afterEach(() => { afterEach(() => {
...@@ -30,81 +35,61 @@ describe('FrequentItemsListItemComponent', () => { ...@@ -30,81 +35,61 @@ describe('FrequentItemsListItemComponent', () => {
describe('computed', () => { describe('computed', () => {
describe('hasAvatar', () => { describe('hasAvatar', () => {
it('should return `true` or `false` if whether avatar is present or not', () => { it('should return `true` or `false` if whether avatar is present or not', () => {
vm.avatarUrl = 'path/to/avatar.png'; wrapper.setProps({ avatarUrl: 'path/to/avatar.png' });
expect(vm.hasAvatar).toBe(true); expect(vm.hasAvatar).toBe(true);
vm.avatarUrl = null; wrapper.setProps({ avatarUrl: null });
expect(vm.hasAvatar).toBe(false); expect(vm.hasAvatar).toBe(false);
}); });
}); });
describe('highlightedItemName', () => { describe('highlightedItemName', () => {
it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', done => { it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => {
vm.matcher = 'lab'; wrapper.setProps({ matcher: 'lab' });
vm.$nextTick() expect(wrapper.find('.js-frequent-items-item-title').html()).toContain(
.then(() => { '<b>L</b><b>a</b><b>b</b>',
expect(vm.$el.querySelector('.js-frequent-items-item-title').innerHTML).toContain( );
'<b>L</b><b>a</b><b>b</b>',
);
})
.then(done)
.catch(done.fail);
}); });
it('should return project name as it is if `matcher` is not available', done => { it('should return project name as it is if `matcher` is not available', () => {
vm.matcher = null; wrapper.setProps({ matcher: null });
vm.$nextTick() expect(trimText(wrapper.find('.js-frequent-items-item-title').text())).toBe(
.then(() => { mockProject.name,
expect(vm.$el.querySelector('.js-frequent-items-item-title').innerHTML).toBe( );
mockProject.name,
);
})
.then(done)
.catch(done.fail);
}); });
}); });
describe('truncatedNamespace', () => { describe('truncatedNamespace', () => {
it('should truncate project name from namespace string', done => { it('should truncate project name from namespace string', () => {
vm.namespace = 'platform / nokia-3310'; wrapper.setProps({ namespace: 'platform / nokia-3310' });
vm.$nextTick() expect(trimText(wrapper.find('.js-frequent-items-item-namespace').text())).toBe('platform');
.then(() => {
expect(
trimText(vm.$el.querySelector('.js-frequent-items-item-namespace').innerHTML),
).toBe('platform');
})
.then(done)
.catch(done.fail);
}); });
it('should truncate namespace string from the middle if it includes more than two groups in path', done => { it('should truncate namespace string from the middle if it includes more than two groups in path', () => {
vm.namespace = 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310'; wrapper.setProps({
namespace: 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310',
vm.$nextTick() });
.then(() => {
expect( expect(trimText(wrapper.find('.js-frequent-items-item-namespace').text())).toBe(
trimText(vm.$el.querySelector('.js-frequent-items-item-namespace').innerHTML), 'platform / ... / Mobile Chipset',
).toBe('platform / ... / Mobile Chipset'); );
})
.then(done)
.catch(done.fail);
}); });
}); });
}); });
describe('template', () => { describe('template', () => {
it('should render component element', () => { it('should render component element', () => {
expect(vm.$el.classList.contains('frequent-items-list-item-container')).toBeTruthy(); expect(wrapper.classes()).toContain('frequent-items-list-item-container');
expect(vm.$el.querySelectorAll('a').length).toBe(1); expect(wrapper.findAll('a').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-avatar-container').length).toBe(1); expect(wrapper.findAll('.frequent-items-item-avatar-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.frequent-items-item-metadata-container').length).toBe(1); expect(wrapper.findAll('.frequent-items-item-metadata-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.js-frequent-items-item-title').length).toBe(1); expect(wrapper.findAll('.frequent-items-item-title').length).toBe(1);
expect(vm.$el.querySelectorAll('.js-frequent-items-item-namespace').length).toBe(1); expect(wrapper.findAll('.frequent-items-item-namespace').length).toBe(1);
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue'; import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
import eventHub from '~/frequent_items/event_hub'; import eventHub from '~/frequent_items/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { shallowMount } from '@vue/test-utils';
const createComponent = (namespace = 'projects') => { const createComponent = (namespace = 'projects') => {
const Component = Vue.extend(searchComponent); const Component = Vue.extend(searchComponent);
return mountComponent(Component, { namespace }); return shallowMount(Component, { propsData: { namespace } });
}; };
describe('FrequentItemsSearchInputComponent', () => { describe('FrequentItemsSearchInputComponent', () => {
let wrapper;
let vm; let vm;
beforeEach(() => { beforeEach(() => {
vm = createComponent(); wrapper = createComponent();
({ vm } = wrapper);
}); });
afterEach(() => { afterEach(() => {
...@@ -35,7 +38,7 @@ describe('FrequentItemsSearchInputComponent', () => { ...@@ -35,7 +38,7 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('mounted', () => { describe('mounted', () => {
it('should listen `dropdownOpen` event', done => { it('should listen `dropdownOpen` event', done => {
spyOn(eventHub, '$on'); spyOn(eventHub, '$on');
const vmX = createComponent(); const vmX = createComponent().vm;
Vue.nextTick(() => { Vue.nextTick(() => {
expect(eventHub.$on).toHaveBeenCalledWith( expect(eventHub.$on).toHaveBeenCalledWith(
...@@ -49,7 +52,7 @@ describe('FrequentItemsSearchInputComponent', () => { ...@@ -49,7 +52,7 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('beforeDestroy', () => { describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', done => { it('should unbind event listeners on eventHub', done => {
const vmX = createComponent(); const vmX = createComponent().vm;
spyOn(eventHub, '$off'); spyOn(eventHub, '$off');
vmX.$mount(); vmX.$mount();
...@@ -67,12 +70,12 @@ describe('FrequentItemsSearchInputComponent', () => { ...@@ -67,12 +70,12 @@ describe('FrequentItemsSearchInputComponent', () => {
describe('template', () => { describe('template', () => {
it('should render component element', () => { it('should render component element', () => {
const inputEl = vm.$el.querySelector('input.form-control'); expect(wrapper.classes()).toContain('search-input-container');
expect(wrapper.contains('input.form-control')).toBe(true);
expect(vm.$el.classList.contains('search-input-container')).toBeTruthy(); expect(wrapper.contains('.search-icon')).toBe(true);
expect(inputEl).not.toBe(null); expect(wrapper.find('input.form-control').attributes('placeholder')).toBe(
expect(inputEl.getAttribute('placeholder')).toBe('Search your projects'); 'Search your projects',
expect(vm.$el.querySelector('.search-icon')).toBeDefined(); );
}); });
}); });
}); });
import _ from 'underscore';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { trimText } from 'spec/helpers/vue_component_helper'; import { trimText } from 'spec/helpers/vue_component_helper';
...@@ -6,99 +5,106 @@ import { trimText } from 'spec/helpers/vue_component_helper'; ...@@ -6,99 +5,106 @@ import { trimText } from 'spec/helpers/vue_component_helper';
const localVue = createLocalVue(); const localVue = createLocalVue();
describe('ProjectListItem component', () => { describe('ProjectListItem component', () => {
const Component = localVue.extend(ProjectListItem);
let wrapper; let wrapper;
let vm; let vm;
let options;
loadJSONFixtures('projects.json'); loadJSONFixtures('projects.json');
const project = getJSONFixture('projects.json')[0]; const project = getJSONFixture('projects.json')[0];
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(localVue.extend(ProjectListItem), { options = {
propsData: { propsData: {
project, project,
selected: false, selected: false,
}, },
sync: false, sync: false,
localVue, localVue,
}); };
({ vm } = wrapper);
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.vm.$destroy();
}); });
it('does not render a check mark icon if selected === false', () => { it('does not render a check mark icon if selected === false', () => {
expect(vm.$el.querySelector('.js-selected-icon.js-unselected')).toBeTruthy(); wrapper = shallowMount(Component, options);
expect(wrapper.contains('.js-selected-icon.js-unselected')).toBe(true);
}); });
it('renders a check mark icon if selected === true', done => { it('renders a check mark icon if selected === true', () => {
wrapper.setProps({ selected: true }); options.propsData.selected = true;
vm.$nextTick(() => { wrapper = shallowMount(Component, options);
expect(vm.$el.querySelector('.js-selected-icon.js-selected')).toBeTruthy();
done(); expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true);
});
}); });
it(`emits a "clicked" event when clicked`, () => { it(`emits a "clicked" event when clicked`, () => {
wrapper = shallowMount(Component, options);
({ vm } = wrapper);
spyOn(vm, '$emit'); spyOn(vm, '$emit');
vm.onClick(); wrapper.vm.onClick();
expect(vm.$emit).toHaveBeenCalledWith('click'); expect(wrapper.vm.$emit).toHaveBeenCalledWith('click');
}); });
it(`renders the project avatar`, () => { it(`renders the project avatar`, () => {
expect(vm.$el.querySelector('.js-project-avatar')).toBeTruthy(); wrapper = shallowMount(Component, options);
expect(wrapper.contains('.js-project-avatar')).toBe(true);
}); });
it(`renders a simple namespace name with a trailing slash`, done => { it(`renders a simple namespace name with a trailing slash`, () => {
project.name_with_namespace = 'a / b'; options.propsData.project.name_with_namespace = 'a / b';
wrapper.setProps({ project: _.clone(project) });
vm.$nextTick(() => { wrapper = shallowMount(Component, options);
const renderedNamespace = trimText(vm.$el.querySelector('.js-project-namespace').textContent); const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
expect(renderedNamespace).toBe('a /'); expect(renderedNamespace).toBe('a /');
done();
});
}); });
it(`renders a properly truncated namespace with a trailing slash`, done => { it(`renders a properly truncated namespace with a trailing slash`, () => {
project.name_with_namespace = 'a / b / c / d / e / f'; options.propsData.project.name_with_namespace = 'a / b / c / d / e / f';
wrapper.setProps({ project: _.clone(project) });
vm.$nextTick(() => { wrapper = shallowMount(Component, options);
const renderedNamespace = trimText(vm.$el.querySelector('.js-project-namespace').textContent); const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
expect(renderedNamespace).toBe('a / ... / e /'); expect(renderedNamespace).toBe('a / ... / e /');
done();
});
}); });
it(`renders the project name`, done => { it(`renders the project name`, () => {
project.name = 'my-test-project'; options.propsData.project.name = 'my-test-project';
wrapper.setProps({ project: _.clone(project) });
vm.$nextTick(() => { wrapper = shallowMount(Component, options);
const renderedName = trimText(vm.$el.querySelector('.js-project-name').innerHTML); const renderedName = trimText(wrapper.find('.js-project-name').text());
expect(renderedName).toBe('my-test-project'); expect(renderedName).toBe('my-test-project');
done();
});
}); });
it(`renders the project name with highlighting in the case of a search query match`, done => { it(`renders the project name with highlighting in the case of a search query match`, () => {
project.name = 'my-test-project'; options.propsData.project.name = 'my-test-project';
wrapper.setProps({ project: _.clone(project), matcher: 'pro' }); options.propsData.matcher = 'pro';
wrapper = shallowMount(Component, options);
const renderedName = trimText(wrapper.find('.js-project-name').html());
const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject';
expect(renderedName).toContain(expected);
});
vm.$nextTick(() => { it('prevents search query and project name XSS', () => {
const renderedName = trimText(vm.$el.querySelector('.js-project-name').innerHTML); const alertSpy = spyOn(window, 'alert');
options.propsData.project.name = "my-xss-pro<script>alert('XSS');</script>ject";
options.propsData.matcher = "pro<script>alert('XSS');</script>";
const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject'; wrapper = shallowMount(Component, options);
const renderedName = trimText(wrapper.find('.js-project-name').html());
const expected = 'my-xss-project';
expect(renderedName).toBe(expected); expect(renderedName).toContain(expected);
done(); expect(alertSpy).not.toHaveBeenCalled();
});
}); });
}); });
...@@ -38,28 +38,25 @@ describe('ProjectSelector component', () => { ...@@ -38,28 +38,25 @@ describe('ProjectSelector component', () => {
}); });
it('renders the search results', () => { it('renders the search results', () => {
expect(vm.$el.querySelectorAll('.js-project-list-item').length).toBe(5); expect(wrapper.findAll('.js-project-list-item').length).toBe(5);
}); });
it(`triggers a (debounced) search when the search input value changes`, done => { it(`triggers a (debounced) search when the search input value changes`, () => {
spyOn(vm, '$emit'); spyOn(vm, '$emit');
const query = 'my test query!'; const query = 'my test query!';
const searchInput = vm.$el.querySelector('.js-project-selector-input'); const searchInput = wrapper.find('.js-project-selector-input');
searchInput.value = query; searchInput.setValue(query);
searchInput.dispatchEvent(new Event('input')); searchInput.trigger('input');
vm.$nextTick(() => { expect(vm.$emit).not.toHaveBeenCalledWith();
expect(vm.$emit).not.toHaveBeenCalledWith(); jasmine.clock().tick(501);
jasmine.clock().tick(501);
expect(vm.$emit).toHaveBeenCalledWith('searched', query); expect(vm.$emit).toHaveBeenCalledWith('searched', query);
done();
});
}); });
it(`debounces the search input`, done => { it(`debounces the search input`, () => {
spyOn(vm, '$emit'); spyOn(vm, '$emit');
const searchInput = vm.$el.querySelector('.js-project-selector-input'); const searchInput = wrapper.find('.js-project-selector-input');
const updateSearchQuery = (count = 0) => { const updateSearchQuery = (count = 0) => {
if (count === 10) { if (count === 10) {
...@@ -67,15 +64,12 @@ describe('ProjectSelector component', () => { ...@@ -67,15 +64,12 @@ describe('ProjectSelector component', () => {
expect(vm.$emit).toHaveBeenCalledTimes(1); expect(vm.$emit).toHaveBeenCalledTimes(1);
expect(vm.$emit).toHaveBeenCalledWith('searched', `search query #9`); expect(vm.$emit).toHaveBeenCalledWith('searched', `search query #9`);
done();
} else { } else {
searchInput.value = `search query #${count}`; searchInput.setValue(`search query #${count}`);
searchInput.dispatchEvent(new Event('input')); searchInput.trigger('input');
vm.$nextTick(() => { jasmine.clock().tick(400);
jasmine.clock().tick(400); updateSearchQuery(count + 1);
updateSearchQuery(count + 1);
});
} }
}; };
...@@ -83,7 +77,7 @@ describe('ProjectSelector component', () => { ...@@ -83,7 +77,7 @@ describe('ProjectSelector component', () => {
}); });
it(`includes a placeholder in the search box`, () => { it(`includes a placeholder in the search box`, () => {
expect(vm.$el.querySelector('.js-project-selector-input').placeholder).toBe( expect(wrapper.find('.js-project-selector-input').attributes('placeholder')).toBe(
'Search your projects', 'Search your projects',
); );
}); });
...@@ -95,58 +89,44 @@ describe('ProjectSelector component', () => { ...@@ -95,58 +89,44 @@ describe('ProjectSelector component', () => {
expect(vm.$emit).toHaveBeenCalledWith('projectClicked', _.first(searchResults)); expect(vm.$emit).toHaveBeenCalledWith('projectClicked', _.first(searchResults));
}); });
it(`shows a "no results" message if showNoResultsMessage === true`, done => { it(`shows a "no results" message if showNoResultsMessage === true`, () => {
wrapper.setProps({ showNoResultsMessage: true }); wrapper.setProps({ showNoResultsMessage: true });
vm.$nextTick(() => { expect(wrapper.contains('.js-no-results-message')).toBe(true);
const noResultsEl = vm.$el.querySelector('.js-no-results-message');
expect(noResultsEl).toBeTruthy();
expect(trimText(noResultsEl.textContent)).toEqual('Sorry, no projects matched your search'); const noResultsEl = wrapper.find('.js-no-results-message');
done(); expect(trimText(noResultsEl.text())).toEqual('Sorry, no projects matched your search');
});
}); });
it(`shows a "minimum seach query" message if showMinimumSearchQueryMessage === true`, done => { it(`shows a "minimum seach query" message if showMinimumSearchQueryMessage === true`, () => {
wrapper.setProps({ showMinimumSearchQueryMessage: true }); wrapper.setProps({ showMinimumSearchQueryMessage: true });
vm.$nextTick(() => { expect(wrapper.contains('.js-minimum-search-query-message')).toBe(true);
const minimumSearchEl = vm.$el.querySelector('.js-minimum-search-query-message');
expect(minimumSearchEl).toBeTruthy();
expect(trimText(minimumSearchEl.textContent)).toEqual( const minimumSearchEl = wrapper.find('.js-minimum-search-query-message');
'Enter at least three characters to search',
);
done(); expect(trimText(minimumSearchEl.text())).toEqual('Enter at least three characters to search');
});
}); });
it(`shows a error message if showSearchErrorMessage === true`, done => { it(`shows a error message if showSearchErrorMessage === true`, () => {
wrapper.setProps({ showSearchErrorMessage: true }); wrapper.setProps({ showSearchErrorMessage: true });
vm.$nextTick(() => { expect(wrapper.contains('.js-search-error-message')).toBe(true);
const errorMessageEl = vm.$el.querySelector('.js-search-error-message');
expect(errorMessageEl).toBeTruthy();
expect(trimText(errorMessageEl.textContent)).toEqual( const errorMessageEl = wrapper.find('.js-search-error-message');
'Something went wrong, unable to search projects',
);
done(); expect(trimText(errorMessageEl.text())).toEqual(
}); 'Something went wrong, unable to search projects',
);
}); });
it(`focuses the input element when the focusSearchInput() method is called`, () => { it(`focuses the input element when the focusSearchInput() method is called`, () => {
const input = vm.$el.querySelector('.js-project-selector-input'); const input = wrapper.find('.js-project-selector-input');
expect(document.activeElement).not.toBe(input); expect(document.activeElement).not.toBe(input.element);
vm.focusSearchInput(); vm.focusSearchInput();
expect(document.activeElement).toBe(input); expect(document.activeElement).toBe(input.element);
}); });
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment