/* eslint no-param-reassign: "off" */ import $ from 'jquery'; import { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete'; import { TEST_HOST } from 'helpers/test_constants'; import { getJSONFixture } from 'helpers/fixtures'; import waitForPromises from 'jest/helpers/wait_for_promises'; import MockAdapter from 'axios-mock-adapter'; import AjaxCache from '~/lib/utils/ajax_cache'; import axios from '~/lib/utils/axios_utils'; const labelsFixture = getJSONFixture('autocomplete_sources/labels.json'); describe('GfmAutoComplete', () => { const fetchDataMock = { fetchData: jest.fn() }; let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock); let atwhoInstance; let sorterValue; let filterValue; describe('DefaultOptions.filter', () => { let items; beforeEach(() => { jest.spyOn(fetchDataMock, 'fetchData'); jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {}); }); describe('assets loading', () => { beforeEach(() => { atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' }; items = ['loading']; filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items); }); it('should call the fetchData function without query', () => { expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '[vulnerability:'); }); it('should not call the default atwho filter', () => { expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); }); it('should return the passed unfiltered items', () => { expect(filterValue).toEqual(items); }); }); describe('backend filtering', () => { beforeEach(() => { atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' }; items = []; }); describe('when previous query is different from current one', () => { beforeEach(() => { gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ previousQuery: 'oldquery', ...fetchDataMock, }); filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items); }); it('should call the fetchData function with query', () => { expect(fetchDataMock.fetchData).toHaveBeenCalledWith( 'inputor', '[vulnerability:', 'newquery', ); }); it('should not call the default atwho filter', () => { expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); }); it('should return the passed unfiltered items', () => { expect(filterValue).toEqual(items); }); }); describe('when previous query is not different from current one', () => { beforeEach(() => { gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ previousQuery: 'oldquery', ...fetchDataMock, }); filterValue = gfmAutoCompleteCallbacks.filter.call( atwhoInstance, 'oldquery', items, 'searchKey', ); }); it('should not call the fetchData function', () => { expect(fetchDataMock.fetchData).not.toHaveBeenCalled(); }); it('should call the default atwho filter', () => { expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith( 'oldquery', items, 'searchKey', ); }); }); }); }); describe('fetchData', () => { const { fetchData } = GfmAutoComplete.prototype; let mock; beforeEach(() => { mock = new MockAdapter(axios); jest.spyOn(axios, 'get'); jest.spyOn(AjaxCache, 'retrieve'); }); afterEach(() => { mock.restore(); }); describe('already loading data', () => { beforeEach(() => { const context = { isLoadingData: { '[vulnerability:': true }, dataSources: {}, cachedData: {}, }; fetchData.call(context, {}, '[vulnerability:', ''); }); it('should not call either axios nor AjaxCache', () => { expect(axios.get).not.toHaveBeenCalled(); expect(AjaxCache.retrieve).not.toHaveBeenCalled(); }); }); describe('backend filtering', () => { describe('data is not in cache', () => { let context; beforeEach(() => { context = { isLoadingData: { '[vulnerability:': false }, dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, cachedData: {}, }; }); it('should call axios with query', () => { fetchData.call(context, {}, '[vulnerability:', 'query'); expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { params: { search: 'query' }, }); }); it.each([200, 500])('should set the loading state', async responseStatus => { mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus); fetchData.call(context, {}, '[vulnerability:', 'query'); expect(context.isLoadingData['[vulnerability:']).toBe(true); await waitForPromises(); expect(context.isLoadingData['[vulnerability:']).toBe(false); }); }); describe('data is in cache', () => { beforeEach(() => { const context = { isLoadingData: { '[vulnerability:': false }, dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, cachedData: { '[vulnerability:': [{}] }, }; fetchData.call(context, {}, '[vulnerability:', 'query'); }); it('should anyway call axios with query ignoring cache', () => { expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { params: { search: 'query' }, }); }); }); }); describe('frontend filtering', () => { describe('data is not in cache', () => { beforeEach(() => { const context = { isLoadingData: { '#': false }, dataSources: { issues: 'issues_autocomplete_url' }, cachedData: {}, }; fetchData.call(context, {}, '#', 'query'); }); it('should call AjaxCache', () => { expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true); }); }); describe('data is in cache', () => { beforeEach(() => { const context = { isLoadingData: { '#': false }, dataSources: { issues: 'issues_autocomplete_url' }, cachedData: { '#': [{}] }, loadData: () => {}, }; fetchData.call(context, {}, '#', 'query'); }); it('should not call AjaxCache', () => { expect(AjaxCache.retrieve).not.toHaveBeenCalled(); }); }); }); }); describe('DefaultOptions.sorter', () => { describe('assets loading', () => { let items; beforeEach(() => { jest.spyOn(GfmAutoComplete, 'isLoading').mockReturnValue(true); atwhoInstance = { setting: {} }; items = []; sorterValue = gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, '', items); }); it('should disable highlightFirst', () => { expect(atwhoInstance.setting.highlightFirst).toBe(false); }); it('should return the passed unfiltered items', () => { expect(sorterValue).toEqual(items); }); }); describe('assets finished loading', () => { beforeEach(() => { jest.spyOn(GfmAutoComplete, 'isLoading').mockReturnValue(false); jest.spyOn($.fn.atwho.default.callbacks, 'sorter').mockImplementation(() => {}); }); it('should enable highlightFirst if alwaysHighlightFirst is set', () => { atwhoInstance = { setting: { alwaysHighlightFirst: true } }; gfmAutoCompleteCallbacks.sorter.call(atwhoInstance); expect(atwhoInstance.setting.highlightFirst).toBe(true); }); it('should enable highlightFirst if a query is present', () => { atwhoInstance = { setting: {} }; gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, 'query'); expect(atwhoInstance.setting.highlightFirst).toBe(true); }); it('should call the default atwho sorter', () => { atwhoInstance = { setting: {} }; const query = 'query'; const items = []; const searchKey = 'searchKey'; gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, query, items, searchKey); expect($.fn.atwho.default.callbacks.sorter).toHaveBeenCalledWith(query, items, searchKey); }); }); }); describe('DefaultOptions.beforeInsert', () => { const beforeInsert = (context, value) => gfmAutoCompleteCallbacks.beforeInsert.call(context, value); beforeEach(() => { atwhoInstance = { setting: { skipSpecialCharacterTest: false } }; }); it('should not quote if value only contains alphanumeric charecters', () => { expect(beforeInsert(atwhoInstance, '@user1')).toBe('@user1'); expect(beforeInsert(atwhoInstance, '~label1')).toBe('~label1'); }); it('should quote if value contains any non-alphanumeric characters', () => { expect(beforeInsert(atwhoInstance, '~label-20')).toBe('~"label-20"'); expect(beforeInsert(atwhoInstance, '~label 20')).toBe('~"label 20"'); }); it('should quote integer labels', () => { expect(beforeInsert(atwhoInstance, '~1234')).toBe('~"1234"'); }); it('escapes Markdown strikethroughs when needed', () => { expect(beforeInsert(atwhoInstance, '~a~bug')).toEqual('~"a~bug"'); expect(beforeInsert(atwhoInstance, '~a~~bug~~')).toEqual('~"a\\~~bug\\~~"'); }); it('escapes Markdown emphasis when needed', () => { expect(beforeInsert(atwhoInstance, '~a_bug_')).toEqual('~a_bug\\_'); expect(beforeInsert(atwhoInstance, '~a _bug_')).toEqual('~"a \\_bug\\_"'); expect(beforeInsert(atwhoInstance, '~a*bug*')).toEqual('~"a\\*bug\\*"'); expect(beforeInsert(atwhoInstance, '~a *bug*')).toEqual('~"a \\*bug\\*"'); }); it('escapes Markdown code spans when needed', () => { expect(beforeInsert(atwhoInstance, '~a`bug`')).toEqual('~"a\\`bug\\`"'); expect(beforeInsert(atwhoInstance, '~a `bug`')).toEqual('~"a \\`bug\\`"'); }); }); describe('DefaultOptions.matcher', () => { const defaultMatcher = (context, flag, subtext) => gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext); const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$']; const otherFlags = ['/', ':']; const flags = flagsUseDefaultMatcher.concat(otherFlags); const flagsHash = flags.reduce((hash, el) => { hash[el] = null; return hash; }, {}); beforeEach(() => { atwhoInstance = { setting: {}, app: { controllers: flagsHash } }; }); const minLen = 1; const maxLen = 20; const argumentSize = [minLen, maxLen / 2, maxLen]; const allowedSymbols = [ '', 'a', 'n', 'z', 'A', 'Z', 'N', '0', '5', '9', 'А', 'а', 'Я', 'я', '.', "'", '-', '_', ]; const jointAllowedSymbols = allowedSymbols.join(''); describe('should match regular symbols', () => { flagsUseDefaultMatcher.forEach(flag => { allowedSymbols.forEach(symbol => { argumentSize.forEach(size => { const query = new Array(size + 1).join(symbol); const subtext = flag + query; it(`matches argument "${flag}" with query "${subtext}"`, () => { expect(defaultMatcher(atwhoInstance, flag, subtext)).toBe(query); }); }); }); it(`matches combination of allowed symbols for flag "${flag}"`, () => { const subtext = flag + jointAllowedSymbols; expect(defaultMatcher(atwhoInstance, flag, subtext)).toBe(jointAllowedSymbols); }); }); }); describe('should not match special sequences', () => { const shouldNotBeFollowedBy = flags.concat(['\x00', '\x10', '\x3f', '\n', ' ']); const shouldNotBePrependedBy = ['`']; flagsUseDefaultMatcher.forEach(atSign => { shouldNotBeFollowedBy.forEach(followedSymbol => { const seq = atSign + followedSymbol; it(`should not match ${JSON.stringify(seq)}`, () => { expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null); }); }); shouldNotBePrependedBy.forEach(prependedSymbol => { const seq = prependedSymbol + atSign; it(`should not match "${seq}"`, () => { expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null); }); }); }); }); }); describe('DefaultOptions.highlighter', () => { beforeEach(() => { atwhoInstance = { setting: {} }; }); it('should return li if no query is given', () => { const liTag = '
  • '; const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag); expect(highlightedTag).toEqual(liTag); }); it('should highlight search query in li element', () => { const liTag = '
  • string
  • '; const query = 's'; const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query); expect(highlightedTag).toEqual('
  • string
  • '); }); it('should highlight search query with special char in li element', () => { const liTag = '
  • te.st
  • '; const query = '.'; const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query); expect(highlightedTag).toEqual('
  • te.st
  • '); }); }); describe('isLoading', () => { it('should be true with loading data object item', () => { expect(GfmAutoComplete.isLoading({ name: 'loading' })).toBe(true); }); it('should be true with loading data array', () => { expect(GfmAutoComplete.isLoading(['loading'])).toBe(true); }); it('should be true with loading data object array', () => { expect(GfmAutoComplete.isLoading([{ name: 'loading' }])).toBe(true); }); it('should be false with actual array data', () => { expect( GfmAutoComplete.isLoading([{ title: 'Foo' }, { title: 'Bar' }, { title: 'Qux' }]), ).toBe(false); }); it('should be false with actual data item', () => { expect(GfmAutoComplete.isLoading({ title: 'Foo' })).toBe(false); }); }); describe('membersBeforeSave', () => { const mockGroup = { username: 'my-group', name: 'My Group', count: 2, avatar_url: './group.jpg', type: 'Group', mentionsDisabled: false, }; it('should return the original object when username is null', () => { expect(membersBeforeSave([{ ...mockGroup, username: null }])).toEqual([ { ...mockGroup, username: null }, ]); }); it('should set the text avatar if avatar_url is null', () => { expect(membersBeforeSave([{ ...mockGroup, avatar_url: null }])).toEqual([ { username: 'my-group', avatarTag: '
    M
    ', title: 'My Group (2)', search: 'my-group My Group', icon: '', }, ]); }); it('should set the image avatar if avatar_url is given', () => { expect(membersBeforeSave([mockGroup])).toEqual([ { username: 'my-group', avatarTag: 'my-group', title: 'My Group (2)', search: 'my-group My Group', icon: '', }, ]); }); it('should set mentions disabled icon if mentionsDisabled is set', () => { expect(membersBeforeSave([{ ...mockGroup, mentionsDisabled: true }])).toEqual([ { username: 'my-group', avatarTag: 'my-group', title: 'My Group', search: 'my-group My Group', icon: '', }, ]); }); it('should set the right image classes for User type members', () => { expect( membersBeforeSave([ { username: 'my-user', name: 'My User', avatar_url: './users.jpg', type: 'User' }, ]), ).toEqual([ { username: 'my-user', avatarTag: 'my-user', title: 'My User', search: 'my-user My User', icon: '', }, ]); }); }); describe('Issues.insertTemplateFunction', () => { it('should return default template', () => { expect(GfmAutoComplete.Issues.insertTemplateFunction({ id: 5, title: 'Some Issue' })).toBe( '${atwho-at}${id}', // eslint-disable-line no-template-curly-in-string ); }); it('should return reference when reference is set', () => { expect( GfmAutoComplete.Issues.insertTemplateFunction({ id: 5, title: 'Some Issue', reference: 'grp/proj#5', }), ).toBe('grp/proj#5'); }); }); describe('Issues.templateFunction', () => { it('should return html with id and title', () => { expect(GfmAutoComplete.Issues.templateFunction({ id: 5, title: 'Some Issue' })).toBe( '
  • 5 Some Issue
  • ', ); }); it('should replace id with reference if reference is set', () => { expect( GfmAutoComplete.Issues.templateFunction({ id: 5, title: 'Some Issue', reference: 'grp/proj#5', }), ).toBe('
  • grp/proj#5 Some Issue
  • '); }); }); describe('Members.templateFunction', () => { it('should return html with avatarTag and username', () => { expect( GfmAutoComplete.Members.templateFunction({ avatarTag: 'IMG', username: 'my-group', title: '', icon: '', availabilityStatus: '', }), ).toBe('
  • IMG my-group
  • '); }); it('should add icon if icon is set', () => { expect( GfmAutoComplete.Members.templateFunction({ avatarTag: 'IMG', username: 'my-group', title: '', icon: '', availabilityStatus: '', }), ).toBe('
  • IMG my-group
  • '); }); it('should add escaped title if title is set', () => { expect( GfmAutoComplete.Members.templateFunction({ avatarTag: 'IMG', username: 'my-group', title: 'MyGroup+', icon: '', availabilityStatus: '', }), ).toBe('
  • IMG my-group MyGroup+
  • '); }); it('should add user availability status if availabilityStatus is set', () => { expect( GfmAutoComplete.Members.templateFunction({ avatarTag: 'IMG', username: 'my-group', title: '', icon: '', availabilityStatus: ' (Busy)', }), ).toBe( '
  • IMG my-group (Busy)
  • ', ); }); }); describe('labels', () => { const dataSources = { labels: `${TEST_HOST}/autocomplete_sources/labels`, }; const allLabels = labelsFixture; const assignedLabels = allLabels.filter(label => label.set); const unassignedLabels = allLabels.filter(label => !label.set); let autocomplete; let $textarea; beforeEach(() => { setFixtures(''); autocomplete = new GfmAutoComplete(dataSources); $textarea = $('textarea'); autocomplete.setup($textarea, { labels: true }); }); afterEach(() => { autocomplete.destroy(); }); const triggerDropdown = text => { $textarea .trigger('focus') .val(text) .caret('pos', -1); $textarea.trigger('keyup'); return new Promise(window.requestAnimationFrame); }; const getDropdownItems = () => { const dropdown = document.getElementById('at-view-labels'); const items = dropdown.getElementsByTagName('li'); return [].map.call(items, item => item.textContent.trim()); }; const expectLabels = ({ input, output }) => triggerDropdown(input).then(() => { expect(getDropdownItems()).toEqual(output.map(label => label.title)); }); describe('with no labels assigned', () => { beforeEach(() => { autocomplete.cachedData['~'] = [...unassignedLabels]; }); it.each` input | output ${'~'} | ${unassignedLabels} ${'/label ~'} | ${unassignedLabels} ${'/relabel ~'} | ${unassignedLabels} ${'/unlabel ~'} | ${[]} `('$input shows $output.length labels', expectLabels); }); describe('with some labels assigned', () => { beforeEach(() => { autocomplete.cachedData['~'] = allLabels; }); it.each` input | output ${'~'} | ${allLabels} ${'/label ~'} | ${unassignedLabels} ${'/relabel ~'} | ${allLabels} ${'/unlabel ~'} | ${assignedLabels} `('$input shows $output.length labels', expectLabels); }); describe('with all labels assigned', () => { beforeEach(() => { autocomplete.cachedData['~'] = [...assignedLabels]; }); it.each` input | output ${'~'} | ${assignedLabels} ${'/label ~'} | ${[]} ${'/relabel ~'} | ${assignedLabels} ${'/unlabel ~'} | ${assignedLabels} `('$input shows $output.length labels', expectLabels); }); }); describe('emoji', () => { const { atom, heart, star } = emojiFixtureMap; const assertInserted = ({ input, subject, emoji }) => expect(subject).toBe(`:${emoji?.name || input}:`); const assertTemplated = ({ input, subject, emoji, field }) => expect(subject.replace(/\s+/g, ' ')).toBe( `
  • ${field || input}
  • `, ); let mock; beforeEach(async () => { mock = await initEmojiMock(); await new GfmAutoComplete({}).loadEmojiData({ atwho() {}, trigger() {} }, ':'); if (!GfmAutoComplete.glEmojiTag) throw new Error('emoji not loaded'); }); afterEach(() => { mock.restore(); }); describe.each` name | inputFormat | assert ${'insertTemplateFunction'} | ${name => ({ name })} | ${assertInserted} ${'templateFunction'} | ${name => name} | ${assertTemplated} `('Emoji.$name', ({ name, inputFormat, assert }) => { const execute = (accessor, input, emoji) => assert({ input, emoji, field: accessor && accessor(emoji), subject: GfmAutoComplete.Emoji[name](inputFormat(input)), }); describeEmojiFields('for $field', ({ accessor }) => { it('should work with lowercase', () => { execute(accessor, accessor(atom), atom); }); it('should work with uppercase', () => { execute(accessor, accessor(atom).toUpperCase(), atom); }); it('should work with partial value', () => { execute(accessor, accessor(atom).slice(1), atom); }); }); it('should work with unicode value', () => { execute(null, atom.moji, atom); }); it('should pass through unknown value', () => { execute(null, 'foo bar baz'); }); }); const expectEmojiOrder = (first, second) => { const keys = Object.keys(emojiFixtureMap); const firstIndex = keys.indexOf(first); const secondIndex = keys.indexOf(second); expect(firstIndex).toBeGreaterThanOrEqual(0); expect(secondIndex).toBeGreaterThanOrEqual(0); expect(firstIndex).toBeLessThan(secondIndex); }; describe('Emoji.insertTemplateFunction', () => { it('should map ":heart" to :heart: [regression]', () => { // the bug mapped heart to black_heart because the latter sorted first expectEmojiOrder('black_heart', 'heart'); const item = GfmAutoComplete.Emoji.insertTemplateFunction({ name: 'heart' }); expect(item).toEqual(`:${heart.name}:`); }); it('should map ":star" to :star: [regression]', () => { // the bug mapped star to custard because the latter sorted first expectEmojiOrder('custard', 'star'); const item = GfmAutoComplete.Emoji.insertTemplateFunction({ name: 'star' }); expect(item).toEqual(`:${star.name}:`); }); }); describe('Emoji.templateFunction', () => { it('should map ":heart" to ❤ [regression]', () => { // the bug mapped heart to black_heart because the latter sorted first expectEmojiOrder('black_heart', 'heart'); const item = GfmAutoComplete.Emoji.templateFunction('heart') .replace(/(\s+|\s+ s.trim()); expect(item).toEqual( `
  • ${heart.name}
  • `, ); }); it('should map ":star" to ⭐ [regression]', () => { // the bug mapped star to custard because the latter sorted first expectEmojiOrder('custard', 'star'); const item = GfmAutoComplete.Emoji.templateFunction('star') .replace(/(\s+|\s+ s.trim()); expect(item).toEqual(`
  • ${star.name}
  • `); }); }); }); });