From 9f46488805e86b1bc341ea1620b866016c2ce5ed Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 20 May 2020 14:34:42 +0000 Subject: Add latest changes from gitlab-org/gitlab@13-0-stable-ee --- .../filtered_search/dropdown_utils_spec.js | 374 +++++++++++++ .../filtered_search_manager_spec.js | 587 +++++++++++++++++++++ .../filtered_search_tokenizer_spec.js | 152 ++++++ .../issues_filtered_search_token_keys_spec.js | 148 ++++++ .../filtered_search/recent_searches_root_spec.js | 32 ++ .../services/recent_searches_service_spec.js | 161 ++++++ .../filtered_search/visual_token_value_spec.js | 389 ++++++++++++++ 7 files changed, 1843 insertions(+) create mode 100644 spec/frontend/filtered_search/dropdown_utils_spec.js create mode 100644 spec/frontend/filtered_search/filtered_search_manager_spec.js create mode 100644 spec/frontend/filtered_search/filtered_search_tokenizer_spec.js create mode 100644 spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js create mode 100644 spec/frontend/filtered_search/recent_searches_root_spec.js create mode 100644 spec/frontend/filtered_search/services/recent_searches_service_spec.js create mode 100644 spec/frontend/filtered_search/visual_token_value_spec.js (limited to 'spec/frontend/filtered_search') diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js new file mode 100644 index 00000000000..3320b6b0942 --- /dev/null +++ b/spec/frontend/filtered_search/dropdown_utils_spec.js @@ -0,0 +1,374 @@ +import DropdownUtils from '~/filtered_search/dropdown_utils'; +import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; + +describe('Dropdown Utils', () => { + const issueListFixture = 'issues/issue_list.html'; + preloadFixtures(issueListFixture); + + describe('getEscapedText', () => { + it('should return same word when it has no space', () => { + const escaped = DropdownUtils.getEscapedText('textWithoutSpace'); + + expect(escaped).toBe('textWithoutSpace'); + }); + + it('should escape with double quotes', () => { + let escaped = DropdownUtils.getEscapedText('text with space'); + + expect(escaped).toBe('"text with space"'); + + escaped = DropdownUtils.getEscapedText("won't fix"); + + expect(escaped).toBe('"won\'t fix"'); + }); + + it('should escape with single quotes', () => { + const escaped = DropdownUtils.getEscapedText('won"t fix'); + + expect(escaped).toBe("'won\"t fix'"); + }); + + it('should escape with single quotes by default', () => { + const escaped = DropdownUtils.getEscapedText('won"t\' fix'); + + expect(escaped).toBe("'won\"t' fix'"); + }); + }); + + describe('filterWithSymbol', () => { + let input; + const item = { + title: '@root', + }; + + beforeEach(() => { + setFixtures(` + + `); + + input = document.getElementById('test'); + }); + + it('should filter without symbol', () => { + input.value = 'roo'; + + const updatedItem = DropdownUtils.filterWithSymbol('@', input, item); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with symbol', () => { + input.value = '@roo'; + + const updatedItem = DropdownUtils.filterWithSymbol('@', input, item); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + describe('filters multiple word title', () => { + const multipleWordItem = { + title: 'Community Contributions', + }; + + it('should filter with double quote', () => { + input.value = '"'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with double quote and symbol', () => { + input.value = '~"'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with double quote and multiple words', () => { + input.value = '"community con'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with double quote, symbol and multiple words', () => { + input.value = '~"community con'; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote', () => { + input.value = "'"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote and symbol', () => { + input.value = "~'"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote and multiple words', () => { + input.value = "'community con"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with single quote, symbol and multiple words', () => { + input.value = "~'community con"; + + const updatedItem = DropdownUtils.filterWithSymbol('~', input, multipleWordItem); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + }); + }); + + describe('filterHint', () => { + let input; + let allowedKeys; + + beforeEach(() => { + setFixtures(` + + `); + + input = document.getElementById('test'); + allowedKeys = IssuableFilteredSearchTokenKeys.getKeys(); + }); + + function config() { + return { + input, + allowedKeys, + }; + } + + it('should filter', () => { + input.value = 'l'; + let updatedItem = DropdownUtils.filterHint(config(), { + hint: 'label', + }); + + expect(updatedItem.droplab_hidden).toBe(false); + + input.value = 'o'; + updatedItem = DropdownUtils.filterHint(config(), { + hint: 'label', + }); + + expect(updatedItem.droplab_hidden).toBe(true); + }); + + it('should return droplab_hidden false when item has no hint', () => { + const updatedItem = DropdownUtils.filterHint(config(), {}, ''); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should allow multiple if item.type is array', () => { + input.value = 'label:~first la'; + const updatedItem = DropdownUtils.filterHint(config(), { + hint: 'label', + type: 'array', + }); + + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should prevent multiple if item.type is not array', () => { + input.value = 'milestone:~first mile'; + let updatedItem = DropdownUtils.filterHint(config(), { + hint: 'milestone', + }); + + expect(updatedItem.droplab_hidden).toBe(true); + + updatedItem = DropdownUtils.filterHint(config(), { + hint: 'milestone', + type: 'string', + }); + + expect(updatedItem.droplab_hidden).toBe(true); + }); + }); + + describe('setDataValueIfSelected', () => { + beforeEach(() => { + jest.spyOn(FilteredSearchDropdownManager, 'addWordToInput').mockImplementation(() => {}); + }); + + it('calls addWordToInput when dataValue exists', () => { + const selected = { + getAttribute: () => 'value', + hasAttribute: () => false, + }; + + DropdownUtils.setDataValueIfSelected(null, '=', selected); + + expect(FilteredSearchDropdownManager.addWordToInput.mock.calls.length).toEqual(1); + }); + + it('returns true when dataValue exists', () => { + const selected = { + getAttribute: () => 'value', + hasAttribute: () => false, + }; + + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); + + expect(result).toBe(true); + expect(result2).toBe(true); + }); + + it('returns false when dataValue does not exist', () => { + const selected = { + getAttribute: () => null, + }; + + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); + + expect(result).toBe(false); + expect(result2).toBe(false); + }); + }); + + describe('getInputSelectionPosition', () => { + describe('word with trailing spaces', () => { + const value = 'label:none '; + + it('should return selectionStart when cursor is at the trailing space', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 11, + value, + }); + + expect(left).toBe(11); + expect(right).toBe(11); + }); + + it('should return input when cursor is at the start of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 0, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(10); + }); + + it('should return input when cursor is at the middle of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 7, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(10); + }); + + it('should return input when cursor is at the end of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 10, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(10); + }); + }); + + describe('multiple words', () => { + const value = 'label:~"Community Contribution"'; + + it('should return input when cursor is after the first word', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 17, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(31); + }); + + it('should return input when cursor is before the second word', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 18, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(31); + }); + }); + + describe('incomplete multiple words', () => { + const value = 'label:~"Community Contribution'; + + it('should return entire input when cursor is at the start of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 0, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(30); + }); + + it('should return entire input when cursor is at the end of input', () => { + const { left, right } = DropdownUtils.getInputSelectionPosition({ + selectionStart: 30, + value, + }); + + expect(left).toBe(0); + expect(right).toBe(30); + }); + }); + }); + + describe('getSearchQuery', () => { + let authorToken; + + beforeEach(() => { + loadFixtures(issueListFixture); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); + + const tokensContainer = document.querySelector('.tokens-container'); + tokensContainer.appendChild(searchTermToken); + tokensContainer.appendChild(authorToken); + }); + + it('uses original value if present', () => { + const originalValue = 'original dance'; + const valueContainer = authorToken.querySelector('.value-container'); + valueContainer.dataset.originalValue = originalValue; + + const searchQuery = DropdownUtils.getSearchQuery(); + + expect(searchQuery).toBe(' search term author:=original dance'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js new file mode 100644 index 00000000000..ef87662a1ef --- /dev/null +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -0,0 +1,587 @@ +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; +import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import '~/lib/utils/common_utils'; +import DropdownUtils from '~/filtered_search/dropdown_utils'; +import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; +import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; +import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; +import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +describe('Filtered Search Manager', () => { + let input; + let manager; + let tokensContainer; + const page = 'issues'; + const placeholder = 'Search or filter results...'; + + function dispatchBackspaceEvent(element, eventType) { + const event = new Event(eventType); + event.keyCode = BACKSPACE_KEY_CODE; + element.dispatchEvent(event); + } + + function dispatchDeleteEvent(element, eventType) { + const event = new Event(eventType); + event.keyCode = DELETE_KEY_CODE; + element.dispatchEvent(event); + } + + function dispatchAltBackspaceEvent(element, eventType) { + const event = new Event(eventType); + event.altKey = true; + event.keyCode = BACKSPACE_KEY_CODE; + element.dispatchEvent(event); + } + + function dispatchCtrlBackspaceEvent(element, eventType) { + const event = new Event(eventType); + event.ctrlKey = true; + event.keyCode = BACKSPACE_KEY_CODE; + element.dispatchEvent(event); + } + + function dispatchMetaBackspaceEvent(element, eventType) { + const event = new Event(eventType); + event.metaKey = true; + event.keyCode = BACKSPACE_KEY_CODE; + element.dispatchEvent(event); + } + + function getVisualTokens() { + return tokensContainer.querySelectorAll('.js-visual-token'); + } + + beforeEach(() => { + setFixtures(` + + `); + + jest.spyOn(FilteredSearchDropdownManager.prototype, 'setDropdown').mockImplementation(); + }); + + const initializeManager = () => { + jest.spyOn(FilteredSearchManager.prototype, 'loadSearchParamsFromURL').mockImplementation(); + jest.spyOn(FilteredSearchManager.prototype, 'tokenChange').mockImplementation(); + jest + .spyOn(FilteredSearchDropdownManager.prototype, 'updateDropdownOffset') + .mockImplementation(); + jest.spyOn(gl.utils, 'getParameterByName').mockReturnValue(null); + jest.spyOn(FilteredSearchVisualTokens, 'unselectTokens'); + + input = document.querySelector('.filtered-search'); + tokensContainer = document.querySelector('.tokens-container'); + manager = new FilteredSearchManager({ page }); + manager.setup(); + }; + + afterEach(() => { + manager.cleanup(); + }); + + describe('class constructor', () => { + const isLocalStorageAvailable = 'isLocalStorageAvailable'; + + beforeEach(() => { + jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(isLocalStorageAvailable); + jest.spyOn(RecentSearchesRoot.prototype, 'render').mockImplementation(); + }); + + it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => { + manager = new FilteredSearchManager({ page }); + + expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); + expect(manager.recentSearchesStore.state).toEqual( + expect.objectContaining({ + isLocalStorageAvailable, + allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(), + }), + ); + }); + }); + + describe('setup', () => { + beforeEach(() => { + manager = new FilteredSearchManager({ page }); + }); + + it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => { + jest + .spyOn(RecentSearchesService.prototype, 'fetch') + .mockImplementation(() => Promise.reject(new RecentSearchesServiceError())); + jest.spyOn(window, 'Flash').mockImplementation(); + + manager.setup(); + + expect(window.Flash).not.toHaveBeenCalled(); + }); + }); + + describe('searchState', () => { + beforeEach(() => { + jest.spyOn(FilteredSearchManager.prototype, 'search').mockImplementation(); + initializeManager(); + }); + + it('should blur button', () => { + const e = { + preventDefault: () => {}, + currentTarget: { + blur: () => {}, + }, + }; + jest.spyOn(e.currentTarget, 'blur'); + manager.searchState(e); + + expect(e.currentTarget.blur).toHaveBeenCalled(); + }); + + it('should not call search if there is no state', () => { + const e = { + preventDefault: () => {}, + currentTarget: { + blur: () => {}, + }, + }; + + manager.searchState(e); + + expect(FilteredSearchManager.prototype.search).not.toHaveBeenCalled(); + }); + + it('should call search when there is state', () => { + const e = { + preventDefault: () => {}, + currentTarget: { + blur: () => {}, + dataset: { + state: 'opened', + }, + }, + }; + + manager.searchState(e); + + expect(FilteredSearchManager.prototype.search).toHaveBeenCalledWith('opened'); + }); + }); + + describe('search', () => { + const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened'; + + beforeEach(() => { + initializeManager(); + }); + + it('should search with a single word', done => { + input.value = 'searchTerm'; + + visitUrl.mockImplementation(url => { + expect(url).toEqual(`${defaultParams}&search=searchTerm`); + done(); + }); + + manager.search(); + }); + + it('should search with multiple words', done => { + input.value = 'awesome search terms'; + + visitUrl.mockImplementation(url => { + expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`); + done(); + }); + + manager.search(); + }); + + it('should search with special characters', done => { + input.value = '~!@#$%^&*()_+{}:<>,.?/'; + + visitUrl.mockImplementation(url => { + expect(url).toEqual( + `${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`, + ); + done(); + }); + + manager.search(); + }); + + it('removes duplicated tokens', done => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} + `); + + visitUrl.mockImplementation(url => { + expect(url).toEqual(`${defaultParams}&label_name[]=bug`); + done(); + }); + + manager.search(); + }); + }); + + describe('handleInputPlaceholder', () => { + beforeEach(() => { + initializeManager(); + }); + + it('should render placeholder when there is no input', () => { + expect(input.placeholder).toEqual(placeholder); + }); + + it('should not render placeholder when there is input', () => { + input.value = 'test words'; + + const event = new Event('input'); + input.dispatchEvent(event); + + expect(input.placeholder).toEqual(''); + }); + + it('should not render placeholder when there are tokens and no input', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + + const event = new Event('input'); + input.dispatchEvent(event); + + expect(input.placeholder).toEqual(''); + }); + }); + + describe('checkForBackspace', () => { + beforeEach(() => { + initializeManager(); + }); + + describe('tokens and no input', () => { + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + }); + + it('removes last token', () => { + jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial'); + dispatchBackspaceEvent(input, 'keyup'); + dispatchBackspaceEvent(input, 'keyup'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled(); + }); + + it('sets the input', () => { + jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial'); + dispatchDeleteEvent(input, 'keyup'); + dispatchDeleteEvent(input, 'keyup'); + + expect(FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled(); + expect(input.value).toEqual('~bug'); + }); + }); + + it('does not remove token or change input when there is existing input', () => { + jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial'); + jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial'); + + input.value = 'text'; + dispatchDeleteEvent(input, 'keyup'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled(); + expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled(); + expect(input.value).toEqual('text'); + }); + + it('does not remove previous token on single backspace press', () => { + jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial'); + jest.spyOn(FilteredSearchVisualTokens, 'getLastTokenPartial'); + + input.value = 't'; + dispatchDeleteEvent(input, 'keyup'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled(); + expect(FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled(); + expect(input.value).toEqual('t'); + }); + }); + + describe('checkForAltOrCtrlBackspace', () => { + beforeEach(() => { + initializeManager(); + jest.spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial'); + }); + + describe('tokens and no input', () => { + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + }); + + it('removes last token via alt-backspace', () => { + dispatchAltBackspaceEvent(input, 'keydown'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled(); + }); + + it('removes last token via ctrl-backspace', () => { + dispatchCtrlBackspaceEvent(input, 'keydown'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled(); + }); + }); + + describe('tokens and input', () => { + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + }); + + it('does not remove token or change input via alt-backspace when there is existing input', () => { + input = manager.filteredSearchInput; + input.value = 'text'; + dispatchAltBackspaceEvent(input, 'keydown'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled(); + expect(input.value).toEqual('text'); + }); + + it('does not remove token or change input via ctrl-backspace when there is existing input', () => { + input = manager.filteredSearchInput; + input.value = 'text'; + dispatchCtrlBackspaceEvent(input, 'keydown'); + + expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled(); + expect(input.value).toEqual('text'); + }); + }); + }); + + describe('checkForMetaBackspace', () => { + beforeEach(() => { + initializeManager(); + }); + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), + ); + }); + + it('removes all tokens and input', () => { + jest.spyOn(FilteredSearchManager.prototype, 'clearSearch'); + dispatchMetaBackspaceEvent(input, 'keydown'); + + expect(manager.clearSearch).toHaveBeenCalled(); + expect(manager.filteredSearchInput.value).toEqual(''); + expect(DropdownUtils.getSearchQuery()).toEqual(''); + }); + }); + + describe('removeToken', () => { + beforeEach(() => { + initializeManager(); + }); + + it('removes token even when it is already selected', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), + ); + + tokensContainer.querySelector('.js-visual-token .remove-token').click(); + + expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null); + }); + + describe('unselected token', () => { + beforeEach(() => { + jest.spyOn(FilteredSearchManager.prototype, 'removeSelectedToken'); + + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'), + ); + tokensContainer.querySelector('.js-visual-token .remove-token').click(); + }); + + it('removes token when remove button is selected', () => { + expect(tokensContainer.querySelector('.js-visual-token')).toEqual(null); + }); + + it('calls removeSelectedToken', () => { + expect(manager.removeSelectedToken).toHaveBeenCalled(); + }); + }); + }); + + describe('removeSelectedTokenKeydown', () => { + beforeEach(() => { + initializeManager(); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), + ); + }); + + it('removes selected token when the backspace key is pressed', () => { + expect(getVisualTokens().length).toEqual(1); + + dispatchBackspaceEvent(document, 'keydown'); + + expect(getVisualTokens().length).toEqual(0); + }); + + it('removes selected token when the delete key is pressed', () => { + expect(getVisualTokens().length).toEqual(1); + + dispatchDeleteEvent(document, 'keydown'); + + expect(getVisualTokens().length).toEqual(0); + }); + + it('updates the input placeholder after removal', () => { + manager.handleInputPlaceholder(); + + expect(input.placeholder).toEqual(''); + expect(getVisualTokens().length).toEqual(1); + + dispatchBackspaceEvent(document, 'keydown'); + + expect(input.placeholder).not.toEqual(''); + expect(getVisualTokens().length).toEqual(0); + }); + + it('updates the clear button after removal', () => { + manager.toggleClearSearchButton(); + + const clearButton = document.querySelector('.clear-search'); + + expect(clearButton.classList.contains('hidden')).toEqual(false); + expect(getVisualTokens().length).toEqual(1); + + dispatchBackspaceEvent(document, 'keydown'); + + expect(clearButton.classList.contains('hidden')).toEqual(true); + expect(getVisualTokens().length).toEqual(0); + }); + }); + + describe('removeSelectedToken', () => { + beforeEach(() => { + jest.spyOn(FilteredSearchVisualTokens, 'removeSelectedToken'); + jest.spyOn(FilteredSearchManager.prototype, 'handleInputPlaceholder'); + jest.spyOn(FilteredSearchManager.prototype, 'toggleClearSearchButton'); + initializeManager(); + }); + + it('calls FilteredSearchVisualTokens.removeSelectedToken', () => { + manager.removeSelectedToken(); + + expect(FilteredSearchVisualTokens.removeSelectedToken).toHaveBeenCalled(); + }); + + it('calls handleInputPlaceholder', () => { + manager.removeSelectedToken(); + + expect(manager.handleInputPlaceholder).toHaveBeenCalled(); + }); + + it('calls toggleClearSearchButton', () => { + manager.removeSelectedToken(); + + expect(manager.toggleClearSearchButton).toHaveBeenCalled(); + }); + + it('calls update dropdown offset', () => { + manager.removeSelectedToken(); + + expect(manager.dropdownManager.updateDropdownOffset).toHaveBeenCalled(); + }); + }); + + describe('Clearing search', () => { + beforeEach(() => { + initializeManager(); + }); + + it('Clicking the "x" clear button, clears the input', () => { + const inputValue = 'label:=~bug'; + manager.filteredSearchInput.value = inputValue; + manager.filteredSearchInput.dispatchEvent(new Event('input')); + + expect(DropdownUtils.getSearchQuery()).toEqual(inputValue); + + manager.clearSearchButton.click(); + + expect(manager.filteredSearchInput.value).toEqual(''); + expect(DropdownUtils.getSearchQuery()).toEqual(''); + }); + }); + + describe('toggleInputContainerFocus', () => { + beforeEach(() => { + initializeManager(); + }); + + it('toggles on focus', () => { + input.focus(); + + expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual( + true, + ); + }); + + it('toggles on blur', () => { + input.blur(); + + expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual( + false, + ); + }); + }); + + describe('getAllParams', () => { + let paramsArr; + beforeEach(() => { + paramsArr = ['key=value', 'otherkey=othervalue']; + + initializeManager(); + }); + + it('correctly modifies params when custom modifier is passed', () => { + const modifedParams = manager.getAllParams.call( + { + modifyUrlParams: params => params.reverse(), + }, + [].concat(paramsArr), + ); + + expect(modifedParams[0]).toBe(paramsArr[1]); + }); + + it('does not modify params when no custom modifier is passed', () => { + const modifedParams = manager.getAllParams.call({}, paramsArr); + + expect(modifedParams[1]).toBe(paramsArr[1]); + }); + }); +}); diff --git a/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js new file mode 100644 index 00000000000..dec03e5ab93 --- /dev/null +++ b/spec/frontend/filtered_search/filtered_search_tokenizer_spec.js @@ -0,0 +1,152 @@ +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer'; + +describe('Filtered Search Tokenizer', () => { + const allowedKeys = IssuableFilteredSearchTokenKeys.getKeys(); + + describe('processTokens', () => { + it('returns for input containing only search value', () => { + const results = FilteredSearchTokenizer.processTokens('searchTerm', allowedKeys); + + expect(results.searchToken).toBe('searchTerm'); + expect(results.tokens.length).toBe(0); + expect(results.lastToken).toBe(results.searchToken); + }); + + it('returns for input containing only tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'author:@root label:~"Very Important" milestone:%v1.0 assignee:none', + allowedKeys, + ); + + expect(results.searchToken).toBe(''); + expect(results.tokens.length).toBe(4); + expect(results.tokens[3]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('"Very Important"'); + expect(results.tokens[1].symbol).toBe('~'); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('v1.0'); + expect(results.tokens[2].symbol).toBe('%'); + + expect(results.tokens[3].key).toBe('assignee'); + expect(results.tokens[3].value).toBe('none'); + expect(results.tokens[3].symbol).toBe(''); + }); + + it('returns for input starting with search value and ending with tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'searchTerm anotherSearchTerm milestone:none', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(1); + expect(results.tokens[0]).toBe(results.lastToken); + expect(results.tokens[0].key).toBe('milestone'); + expect(results.tokens[0].value).toBe('none'); + expect(results.tokens[0].symbol).toBe(''); + }); + + it('returns for input starting with tokens and ending with search value', () => { + const results = FilteredSearchTokenizer.processTokens( + 'assignee:@user searchTerm', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm'); + expect(results.tokens.length).toBe(1); + expect(results.tokens[0].key).toBe('assignee'); + expect(results.tokens[0].value).toBe('user'); + expect(results.tokens[0].symbol).toBe('@'); + expect(results.lastToken).toBe(results.searchToken); + }); + + it('returns for input containing search value wrapped between tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(3); + expect(results.tokens[2]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('"Won\'t fix"'); + expect(results.tokens[1].symbol).toBe('~'); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('none'); + expect(results.tokens[2].symbol).toBe(''); + }); + + it('returns for input containing search value in between tokens', () => { + const results = FilteredSearchTokenizer.processTokens( + 'author:@root searchTerm assignee:none anotherSearchTerm label:~Doing', + allowedKeys, + ); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(3); + expect(results.tokens[2]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('assignee'); + expect(results.tokens[1].value).toBe('none'); + expect(results.tokens[1].symbol).toBe(''); + + expect(results.tokens[2].key).toBe('label'); + expect(results.tokens[2].value).toBe('Doing'); + expect(results.tokens[2].symbol).toBe('~'); + }); + + it('returns search value for invalid tokens', () => { + const results = FilteredSearchTokenizer.processTokens('fake:token', allowedKeys); + + expect(results.lastToken).toBe('fake:token'); + expect(results.searchToken).toBe('fake:token'); + expect(results.tokens.length).toEqual(0); + }); + + it('returns search value and token for mix of valid and invalid tokens', () => { + const results = FilteredSearchTokenizer.processTokens('label:real fake:token', allowedKeys); + + expect(results.tokens.length).toEqual(1); + expect(results.tokens[0].key).toBe('label'); + expect(results.tokens[0].value).toBe('real'); + expect(results.tokens[0].symbol).toBe(''); + expect(results.lastToken).toBe('fake:token'); + expect(results.searchToken).toBe('fake:token'); + }); + + it('returns search value for invalid symbols', () => { + const results = FilteredSearchTokenizer.processTokens('std::includes', allowedKeys); + + expect(results.lastToken).toBe('std::includes'); + expect(results.searchToken).toBe('std::includes'); + }); + + it('removes duplicated values', () => { + const results = FilteredSearchTokenizer.processTokens('label:~foo label:~foo', allowedKeys); + + expect(results.tokens.length).toBe(1); + expect(results.tokens[0].key).toBe('label'); + expect(results.tokens[0].value).toBe('foo'); + expect(results.tokens[0].symbol).toBe('~'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js new file mode 100644 index 00000000000..c7be900ba2c --- /dev/null +++ b/spec/frontend/filtered_search/issues_filtered_search_token_keys_spec.js @@ -0,0 +1,148 @@ +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; + +describe('Issues Filtered Search Token Keys', () => { + describe('get', () => { + let tokenKeys; + + beforeEach(() => { + tokenKeys = IssuableFilteredSearchTokenKeys.get(); + }); + + it('should return tokenKeys', () => { + expect(tokenKeys).not.toBeNull(); + }); + + it('should return tokenKeys as an array', () => { + expect(tokenKeys instanceof Array).toBe(true); + }); + + it('should always return the same array', () => { + const tokenKeys2 = IssuableFilteredSearchTokenKeys.get(); + + expect(tokenKeys).toEqual(tokenKeys2); + }); + + it('should return assignee as a string', () => { + const assignee = tokenKeys.find(tokenKey => tokenKey.key === 'assignee'); + + expect(assignee.type).toEqual('string'); + }); + }); + + describe('getKeys', () => { + it('should return keys', () => { + const getKeys = IssuableFilteredSearchTokenKeys.getKeys(); + const keys = IssuableFilteredSearchTokenKeys.get().map(i => i.key); + + keys.forEach((key, i) => { + expect(key).toEqual(getKeys[i]); + }); + }); + }); + + describe('getConditions', () => { + let conditions; + + beforeEach(() => { + conditions = IssuableFilteredSearchTokenKeys.getConditions(); + }); + + it('should return conditions', () => { + expect(conditions).not.toBeNull(); + }); + + it('should return conditions as an array', () => { + expect(conditions instanceof Array).toBe(true); + }); + }); + + describe('searchByKey', () => { + it('should return null when key not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchByKey('notakey'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by key', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchByKey(tokenKeys[0].key); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchBySymbol', () => { + it('should return null when symbol not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchBySymbol('notasymbol'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by symbol', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByKeyParam', () => { + it('should return null when key param not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchByKeyParam('notakeyparam'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by key param', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchByKeyParam( + `${tokenKeys[0].key}_${tokenKeys[0].param}`, + ); + + expect(result).toEqual(tokenKeys[0]); + }); + + it('should return alternative tokenKey when found by key param', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.getAlternatives(); + const result = IssuableFilteredSearchTokenKeys.searchByKeyParam( + `${tokenKeys[0].key}_${tokenKeys[0].param}`, + ); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByConditionUrl', () => { + it('should return null when condition url not found', () => { + const condition = IssuableFilteredSearchTokenKeys.searchByConditionUrl(null); + + expect(condition).toBeNull(); + }); + + it('should return condition when found by url', () => { + const conditions = IssuableFilteredSearchTokenKeys.getConditions(); + const result = IssuableFilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url); + + expect(result).toBe(conditions[0]); + }); + }); + + describe('searchByConditionKeyValue', () => { + it('should return null when condition tokenKey and value not found', () => { + const condition = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(null, null); + + expect(condition).toBeNull(); + }); + + it('should return condition when found by tokenKey and value', () => { + const conditions = IssuableFilteredSearchTokenKeys.getConditions(); + const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue( + conditions[0].tokenKey, + conditions[0].operator, + conditions[0].value, + ); + + expect(result).toEqual(conditions[0]); + }); + }); +}); diff --git a/spec/frontend/filtered_search/recent_searches_root_spec.js b/spec/frontend/filtered_search/recent_searches_root_spec.js new file mode 100644 index 00000000000..281d406e013 --- /dev/null +++ b/spec/frontend/filtered_search/recent_searches_root_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; + +jest.mock('vue'); + +describe('RecentSearchesRoot', () => { + describe('render', () => { + let recentSearchesRoot; + let data; + let template; + + beforeEach(() => { + recentSearchesRoot = { + store: { + state: 'state', + }, + }; + + Vue.mockImplementation(options => { + ({ data, template } = options); + }); + + RecentSearchesRoot.prototype.render.call(recentSearchesRoot); + }); + + it('should instantiate Vue', () => { + expect(Vue).toHaveBeenCalled(); + expect(data()).toBe(recentSearchesRoot.store.state); + expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js new file mode 100644 index 00000000000..a89d38b7a20 --- /dev/null +++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js @@ -0,0 +1,161 @@ +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +useLocalStorageSpy(); + +describe('RecentSearchesService', () => { + let service; + + beforeEach(() => { + service = new RecentSearchesService(); + localStorage.removeItem(service.localStorageKey); + }); + + describe('fetch', () => { + beforeEach(() => { + jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true); + }); + + it('should default to empty array', done => { + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then(items => { + expect(items).toEqual([]); + }) + .then(done) + .catch(done.fail); + }); + + it('should reject when unable to parse', done => { + jest.spyOn(localStorage, 'getItem').mockReturnValue('fail'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then(done.fail) + .catch(error => { + expect(error).toEqual(expect.any(SyntaxError)); + }) + .then(done) + .catch(done.fail); + }); + + it('should reject when service is unavailable', done => { + RecentSearchesService.isAvailable.mockReturnValue(false); + + service + .fetch() + .then(done.fail) + .catch(error => { + expect(error).toEqual(expect.any(Error)); + }) + .then(done) + .catch(done.fail); + }); + + it('should return items from localStorage', done => { + jest.spyOn(localStorage, 'getItem').mockReturnValue('["foo", "bar"]'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then(items => { + expect(items).toEqual(['foo', 'bar']); + }) + .then(done) + .catch(done.fail); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.mockReturnValue(false); + + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {}); + }); + + it('should not call .getItem', done => { + RecentSearchesService.prototype + .fetch() + .then(done.fail) + .catch(err => { + expect(err).toEqual(new RecentSearchesServiceError()); + expect(localStorage.getItem).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('setRecentSearches', () => { + beforeEach(() => { + jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true); + }); + + it('should save things in localStorage', () => { + jest.spyOn(localStorage, 'setItem'); + const items = ['foo', 'bar']; + service.save(items); + + expect(localStorage.setItem).toHaveBeenCalledWith(expect.any(String), JSON.stringify(items)); + }); + }); + + describe('save', () => { + beforeEach(() => { + jest.spyOn(localStorage, 'setItem'); + jest.spyOn(RecentSearchesService, 'isAvailable').mockImplementation(() => {}); + }); + + describe('if .isAvailable returns `true`', () => { + const searchesString = 'searchesString'; + const localStorageKey = 'localStorageKey'; + const recentSearchesService = { + localStorageKey, + }; + + beforeEach(() => { + RecentSearchesService.isAvailable.mockReturnValue(true); + + jest.spyOn(JSON, 'stringify').mockReturnValue(searchesString); + }); + + it('should call .setItem', () => { + RecentSearchesService.prototype.save.call(recentSearchesService); + + expect(localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString); + }); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.mockReturnValue(false); + }); + + it('should not call .setItem', () => { + RecentSearchesService.prototype.save(); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); + + describe('isAvailable', () => { + let isAvailable; + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe'); + + isAvailable = RecentSearchesService.isAvailable(); + }); + + it('should call .isLocalStorageAccessSafe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + }); + + it('should return a boolean', () => { + expect(typeof isAvailable).toBe('boolean'); + }); + }); +}); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js new file mode 100644 index 00000000000..ea501423403 --- /dev/null +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -0,0 +1,389 @@ +import { escape } from 'lodash'; +import VisualTokenValue from '~/filtered_search/visual_token_value'; +import AjaxCache from '~/lib/utils/ajax_cache'; +import UsersCache from '~/lib/utils/users_cache'; +import DropdownUtils from '~/filtered_search//dropdown_utils'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; + +describe('Filtered Search Visual Tokens', () => { + const findElements = tokenElement => { + const tokenNameElement = tokenElement.querySelector('.name'); + const tokenValueContainer = tokenElement.querySelector('.value-container'); + const tokenValueElement = tokenValueContainer.querySelector('.value'); + const tokenOperatorElement = tokenElement.querySelector('.operator'); + const tokenType = tokenNameElement.innerText.toLowerCase(); + const tokenValue = tokenValueElement.innerText; + const tokenOperator = tokenOperatorElement.innerText; + const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator); + return { subject, tokenValueContainer, tokenValueElement }; + }; + + let tokensContainer; + let authorToken; + let bugLabelToken; + + beforeEach(() => { + setFixtures(` + + `); + tokensContainer = document.querySelector('.tokens-container'); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug'); + }); + + describe('updateUserTokenAppearance', () => { + let usersCacheSpy; + + beforeEach(() => { + jest.spyOn(UsersCache, 'retrieve').mockImplementation(username => usersCacheSpy(username)); + }); + + it('ignores error if UsersCache throws', done => { + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + const dummyError = new Error('Earth rotated backwards'); + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.reject(dummyError); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(window.Flash.mock.calls.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('does nothing if user cannot be found', done => { + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(undefined); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueElement.innerText).toBe(tokenValue); + }) + .then(done) + .catch(done.fail); + }); + + it('replaces author token with avatar and display name', done => { + const dummyUser = { + name: 'Important Person', + avatar_url: 'https://host.invalid/mypics/avatar.png', + }; + const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = username => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(dummyUser); + }; + + subject + .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + const avatar = tokenValueElement.querySelector('img.avatar'); + + expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url); + expect(avatar.getAttribute('alt')).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + + it('escapes user name when creating token', done => { + const dummyUser = { + name: '