diff options
Diffstat (limited to 'spec/frontend/lib/utils')
25 files changed, 614 insertions, 201 deletions
diff --git a/spec/frontend/lib/utils/axios_startup_calls_spec.js b/spec/frontend/lib/utils/axios_startup_calls_spec.js index 4471b781446..3d063ff9b46 100644 --- a/spec/frontend/lib/utils/axios_startup_calls_spec.js +++ b/spec/frontend/lib/utils/axios_startup_calls_spec.js @@ -113,17 +113,10 @@ describe('setupAxiosStartupCalls', () => { }); describe('startup call', () => { - let oldGon; - beforeEach(() => { - oldGon = window.gon; window.gon = { gitlab_url: 'https://example.org/gitlab' }; }); - afterEach(() => { - window.gon = oldGon; - }); - it('removes GitLab Base URL from startup call', async () => { window.gl.startup_calls = { '/startup': { diff --git a/spec/frontend/lib/utils/chart_utils_spec.js b/spec/frontend/lib/utils/chart_utils_spec.js index 65bb68c5017..3b34b0ef672 100644 --- a/spec/frontend/lib/utils/chart_utils_spec.js +++ b/spec/frontend/lib/utils/chart_utils_spec.js @@ -1,4 +1,8 @@ -import { firstAndLastY } from '~/lib/utils/chart_utils'; +import { firstAndLastY, getToolboxOptions } from '~/lib/utils/chart_utils'; +import { __ } from '~/locale'; +import * as iconUtils from '~/lib/utils/icon_utils'; + +jest.mock('~/lib/utils/icon_utils'); describe('Chart utils', () => { describe('firstAndLastY', () => { @@ -12,4 +16,53 @@ describe('Chart utils', () => { expect(firstAndLastY(data)).toEqual([1, 3]); }); }); + + describe('getToolboxOptions', () => { + describe('when icons are successfully fetched', () => { + beforeEach(() => { + iconUtils.getSvgIconPathContent.mockImplementation((name) => + Promise.resolve(`${name}-svg-path-mock`), + ); + }); + + it('returns toolbox config', async () => { + await expect(getToolboxOptions()).resolves.toEqual({ + toolbox: { + feature: { + dataZoom: { + icon: { + zoom: 'path://marquee-selection-svg-path-mock', + back: 'path://redo-svg-path-mock', + }, + }, + restore: { + icon: 'path://repeat-svg-path-mock', + }, + saveAsImage: { + icon: 'path://download-svg-path-mock', + }, + }, + }, + }); + }); + }); + + describe('when icons are not successfully fetched', () => { + const error = new Error(); + + beforeEach(() => { + iconUtils.getSvgIconPathContent.mockRejectedValue(error); + jest.spyOn(console, 'warn').mockImplementation(); + }); + + it('returns empty object and calls `console.warn`', async () => { + await expect(getToolboxOptions()).resolves.toEqual({}); + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith( + __('SVG could not be rendered correctly: '), + error, + ); + }); + }); + }); }); diff --git a/spec/frontend/lib/utils/color_utils_spec.js b/spec/frontend/lib/utils/color_utils_spec.js index 87966cf9fba..92ac66c19f0 100644 --- a/spec/frontend/lib/utils/color_utils_spec.js +++ b/spec/frontend/lib/utils/color_utils_spec.js @@ -1,44 +1,6 @@ -import { - isValidColorExpression, - textColorForBackground, - hexToRgb, - validateHexColor, - darkModeEnabled, -} from '~/lib/utils/color_utils'; +import { isValidColorExpression, validateHexColor, darkModeEnabled } from '~/lib/utils/color_utils'; describe('Color utils', () => { - describe('Converting hex code to rgb', () => { - it('convert hex code to rgb', () => { - expect(hexToRgb('#000000')).toEqual([0, 0, 0]); - expect(hexToRgb('#ffffff')).toEqual([255, 255, 255]); - }); - - it('convert short hex code to rgb', () => { - expect(hexToRgb('#000')).toEqual([0, 0, 0]); - expect(hexToRgb('#fff')).toEqual([255, 255, 255]); - }); - - it('handle conversion regardless of the characters case', () => { - expect(hexToRgb('#f0F')).toEqual([255, 0, 255]); - }); - }); - - describe('Getting text color for given background', () => { - // following tests are being ported from `text_color_for_bg` section in labels_helper_spec.rb - it('uses light text on dark backgrounds', () => { - expect(textColorForBackground('#222E2E')).toEqual('#FFFFFF'); - }); - - it('uses dark text on light backgrounds', () => { - expect(textColorForBackground('#EEEEEE')).toEqual('#333333'); - }); - - it('supports RGB triplets', () => { - expect(textColorForBackground('#FFF')).toEqual('#333333'); - expect(textColorForBackground('#000')).toEqual('#FFFFFF'); - }); - }); - describe('Validate hex color', () => { it.each` color | output @@ -63,7 +25,7 @@ describe('Color utils', () => { ${'groups:issues:index'} | ${'gl-dark'} | ${'monokai-light'} | ${true} `( 'is $expected on $page with $bodyClass body class and $ideTheme IDE theme', - async ({ page, bodyClass, ideTheme, expected }) => { + ({ page, bodyClass, ideTheme, expected }) => { document.body.outerHTML = `<body class="${bodyClass}" data-page="${page}"></body>`; window.gon = { user_color_scheme: ideTheme, diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 7b068f7d248..b4ec00ab766 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -534,18 +534,10 @@ describe('common_utils', () => { }); describe('spriteIcon', () => { - let beforeGon; - beforeEach(() => { - window.gon = window.gon || {}; - beforeGon = { ...window.gon }; window.gon.sprite_icons = 'icons.svg'; }); - afterEach(() => { - window.gon = beforeGon; - }); - it('should return the svg for a linked icon', () => { expect(commonUtils.spriteIcon('test')).toEqual( '<svg ><use xlink:href="icons.svg#test" /></svg>', diff --git a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_action_spec.js b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_action_spec.js index 142c76f7bc0..fab5a7b8844 100644 --- a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_action_spec.js +++ b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_action_spec.js @@ -44,7 +44,6 @@ describe('confirmAction', () => { resetHTMLFixture(); Vue.prototype.$mount.mockRestore(); modalWrapper?.destroy(); - modalWrapper = null; modal?.destroy(); modal = null; }); @@ -67,6 +66,7 @@ describe('confirmAction', () => { modalHtmlMessage: '<strong>Hello</strong>', title: 'title', hideCancel: true, + size: 'md', }; await renderRootComponent('', options); expect(modal.props()).toEqual( @@ -80,6 +80,7 @@ describe('confirmAction', () => { modalHtmlMessage: options.modalHtmlMessage, title: options.title, hideCancel: options.hideCancel, + size: 'md', }), ); }); diff --git a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js index 313e028d861..9dcb850076c 100644 --- a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js +++ b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js @@ -14,6 +14,7 @@ describe('Confirm Modal', () => { secondaryText, secondaryVariant, title, + size, hideCancel = false, } = {}) => { wrapper = mount(ConfirmModal, { @@ -24,14 +25,11 @@ describe('Confirm Modal', () => { secondaryVariant, hideCancel, title, + size, }, }); }; - afterEach(() => { - wrapper.destroy(); - }); - const findGlModal = () => wrapper.findComponent(GlModal); describe('Modal events', () => { @@ -95,5 +93,17 @@ describe('Confirm Modal', () => { expect(findGlModal().props().title).toBe(title); }); + + it('should set modal size to `sm` by default', () => { + createComponent(); + + expect(findGlModal().props('size')).toBe('sm'); + }); + + it('should set modal size when `size` prop is set', () => { + createComponent({ size: 'md' }); + + expect(findGlModal().props('size')).toBe('md'); + }); }); }); diff --git a/spec/frontend/lib/utils/css_utils_spec.js b/spec/frontend/lib/utils/css_utils_spec.js new file mode 100644 index 00000000000..dcaeb075c93 --- /dev/null +++ b/spec/frontend/lib/utils/css_utils_spec.js @@ -0,0 +1,22 @@ +import { getCssClassDimensions } from '~/lib/utils/css_utils'; + +describe('getCssClassDimensions', () => { + const mockDimensions = { width: 1, height: 2 }; + let actual; + + beforeEach(() => { + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(mockDimensions); + actual = getCssClassDimensions('foo bar'); + }); + + it('returns the measured width and height', () => { + expect(actual).toEqual(mockDimensions); + }); + + it('measures an element with the given classes', () => { + expect(Element.prototype.getBoundingClientRect).toHaveBeenCalledTimes(1); + + const [tempElement] = Element.prototype.getBoundingClientRect.mock.contexts; + expect([...tempElement.classList]).toEqual(['foo', 'bar']); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js index a83b0ed9fbe..e7a6367eeac 100644 --- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -134,18 +134,6 @@ describe('formatTimeAsSummary', () => { }); }); -describe('durationTimeFormatted', () => { - it.each` - duration | expectedOutput - ${87} | ${'00:01:27'} - ${141} | ${'00:02:21'} - ${12} | ${'00:00:12'} - ${60} | ${'00:01:00'} - `('returns $expectedOutput when provided $duration', ({ duration, expectedOutput }) => { - expect(utils.durationTimeFormatted(duration)).toBe(expectedOutput); - }); -}); - describe('formatUtcOffset', () => { it.each` offset | expected diff --git a/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js new file mode 100644 index 00000000000..15e056e45d0 --- /dev/null +++ b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js @@ -0,0 +1,25 @@ +import { formatTimeSpent } from '~/lib/utils/datetime/time_spent_utility'; + +describe('Time spent utils', () => { + describe('formatTimeSpent', () => { + describe('with limitToHours false', () => { + it('formats 34500 seconds to `1d 1h 35m`', () => { + expect(formatTimeSpent(34500)).toEqual('1d 1h 35m'); + }); + + it('formats -34500 seconds to `- 1d 1h 35m`', () => { + expect(formatTimeSpent(-34500)).toEqual('- 1d 1h 35m'); + }); + }); + + describe('with limitToHours true', () => { + it('formats 34500 seconds to `9h 35m`', () => { + expect(formatTimeSpent(34500, true)).toEqual('9h 35m'); + }); + + it('formats -34500 seconds to `- 9h 35m`', () => { + expect(formatTimeSpent(-34500, true)).toEqual('- 9h 35m'); + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js index 1ef7047d959..74ce8175357 100644 --- a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js @@ -1,18 +1,9 @@ +import { DATE_ONLY_FORMAT } from '~/lib/utils/datetime/constants'; import { getTimeago, localTimeAgo, timeFor, duration } from '~/lib/utils/datetime/timeago_utility'; import { s__ } from '~/locale'; import '~/commons/bootstrap'; describe('TimeAgo utils', () => { - let oldGon; - - afterEach(() => { - window.gon = oldGon; - }); - - beforeEach(() => { - oldGon = window.gon; - }); - describe('getTimeago', () => { describe('with User Setting timeDisplayRelative: true', () => { beforeEach(() => { @@ -34,15 +25,37 @@ describe('TimeAgo utils', () => { window.gon = { time_display_relative: false }; }); - it.each([ + const defaultFormatExpectations = [ [new Date().toISOString(), 'Jul 6, 2020, 12:00 AM'], [new Date(), 'Jul 6, 2020, 12:00 AM'], [new Date().getTime(), 'Jul 6, 2020, 12:00 AM'], // Slightly different behaviour when `null` is passed :see_no_evil` [null, 'Jan 1, 1970, 12:00 AM'], - ])('formats date `%p` as `%p`', (date, result) => { + ]; + + it.each(defaultFormatExpectations)('formats date `%p` as `%p`', (date, result) => { expect(getTimeago().format(date)).toEqual(result); }); + + describe('with unknown format', () => { + it.each(defaultFormatExpectations)( + 'uses default format and formats date `%p` as `%p`', + (date, result) => { + expect(getTimeago('non_existent').format(date)).toEqual(result); + }, + ); + }); + + describe('with DATE_ONLY_FORMAT', () => { + it.each([ + [new Date().toISOString(), 'Jul 6, 2020'], + [new Date(), 'Jul 6, 2020'], + [new Date().getTime(), 'Jul 6, 2020'], + [null, 'Jan 1, 1970'], + ])('formats date `%p` as `%p`', (date, result) => { + expect(getTimeago(DATE_ONLY_FORMAT).format(date)).toEqual(result); + }); + }); }); }); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 8d989350173..330bfca7029 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -276,19 +276,35 @@ describe('getTimeframeWindowFrom', () => { }); describe('formatTime', () => { - const expectedTimestamps = [ - [0, '00:00:00'], - [1000, '00:00:01'], - [42000, '00:00:42'], - [121000, '00:02:01'], - [10921000, '03:02:01'], - [108000000, '30:00:00'], - ]; + it.each` + milliseconds | expected + ${0} | ${'00:00:00'} + ${1} | ${'00:00:00'} + ${499} | ${'00:00:00'} + ${500} | ${'00:00:01'} + ${1000} | ${'00:00:01'} + ${42 * 1000} | ${'00:00:42'} + ${60 * 1000} | ${'00:01:00'} + ${(60 + 1) * 1000} | ${'00:01:01'} + ${(3 * 60 * 60 + 2 * 60 + 1) * 1000} | ${'03:02:01'} + ${(11 * 60 * 60 + 59 * 60 + 59) * 1000} | ${'11:59:59'} + ${30 * 60 * 60 * 1000} | ${'30:00:00'} + ${(35 * 60 * 60 + 3 * 60 + 7) * 1000} | ${'35:03:07'} + ${240 * 60 * 60 * 1000} | ${'240:00:00'} + ${1000 * 60 * 60 * 1000} | ${'1000:00:00'} + `(`formats $milliseconds ms as $expected`, ({ milliseconds, expected }) => { + expect(datetimeUtility.formatTime(milliseconds)).toBe(expected); + }); - expectedTimestamps.forEach(([milliseconds, expectedTimestamp]) => { - it(`formats ${milliseconds}ms as ${expectedTimestamp}`, () => { - expect(datetimeUtility.formatTime(milliseconds)).toBe(expectedTimestamp); - }); + it.each` + milliseconds | expected + ${-1} | ${'00:00:00'} + ${-499} | ${'00:00:00'} + ${-1000} | ${'-00:00:01'} + ${-60 * 1000} | ${'-00:01:00'} + ${-(35 * 60 * 60 + 3 * 60 + 7) * 1000} | ${'-35:03:07'} + `(`when negative, formats $milliseconds ms as $expected`, ({ milliseconds, expected }) => { + expect(datetimeUtility.formatTime(milliseconds)).toBe(expected); }); }); diff --git a/spec/frontend/lib/utils/error_message_spec.js b/spec/frontend/lib/utils/error_message_spec.js new file mode 100644 index 00000000000..d55a6de06c3 --- /dev/null +++ b/spec/frontend/lib/utils/error_message_spec.js @@ -0,0 +1,48 @@ +import { parseErrorMessage } from '~/lib/utils/error_message'; + +const defaultErrorMessage = 'Default error message'; +const errorMessage = 'Returned error message'; + +const generateErrorWithMessage = (message) => { + return { + message, + }; +}; + +describe('parseErrorMessage', () => { + const ufErrorPrefix = 'Foo:'; + beforeEach(() => { + gon.uf_error_prefix = ufErrorPrefix; + }); + + it.each` + error | expectedResult + ${`${ufErrorPrefix} ${errorMessage}`} | ${errorMessage} + ${`${errorMessage} ${ufErrorPrefix}`} | ${defaultErrorMessage} + ${errorMessage} | ${defaultErrorMessage} + ${undefined} | ${defaultErrorMessage} + ${''} | ${defaultErrorMessage} + `( + 'properly parses "$error" error object and returns "$expectedResult"', + ({ error, expectedResult }) => { + const errorObject = generateErrorWithMessage(error); + expect(parseErrorMessage(errorObject, defaultErrorMessage)).toEqual(expectedResult); + }, + ); + + it.each` + error | defaultMessage | expectedResult + ${undefined} | ${defaultErrorMessage} | ${defaultErrorMessage} + ${''} | ${defaultErrorMessage} | ${defaultErrorMessage} + ${{}} | ${defaultErrorMessage} | ${defaultErrorMessage} + ${generateErrorWithMessage(errorMessage)} | ${undefined} | ${''} + ${generateErrorWithMessage(`${ufErrorPrefix} ${errorMessage}`)} | ${undefined} | ${errorMessage} + ${generateErrorWithMessage(errorMessage)} | ${''} | ${''} + ${generateErrorWithMessage(`${ufErrorPrefix} ${errorMessage}`)} | ${''} | ${errorMessage} + `( + 'properly handles the edge case of error="$error" and defaultMessage="$defaultMessage"', + ({ error, defaultMessage, expectedResult }) => { + expect(parseErrorMessage(error, defaultMessage)).toEqual(expectedResult); + }, + ); +}); diff --git a/spec/frontend/lib/utils/file_upload_spec.js b/spec/frontend/lib/utils/file_upload_spec.js index f63af2fe0a4..509ddc7ce86 100644 --- a/spec/frontend/lib/utils/file_upload_spec.js +++ b/spec/frontend/lib/utils/file_upload_spec.js @@ -1,5 +1,9 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import fileUpload, { getFilename, validateImageName } from '~/lib/utils/file_upload'; +import fileUpload, { + getFilename, + validateImageName, + validateFileFromAllowList, +} from '~/lib/utils/file_upload'; describe('File upload', () => { beforeEach(() => { @@ -89,3 +93,19 @@ describe('file name validator', () => { expect(validateImageName(file)).toBe('image.png'); }); }); + +describe('validateFileFromAllowList', () => { + it('returns true if the file type is in the allowed list', () => { + const allowList = ['.foo', '.bar']; + const fileName = 'file.foo'; + + expect(validateFileFromAllowList(fileName, allowList)).toBe(true); + }); + + it('returns false if the file type is in the allowed list', () => { + const allowList = ['.foo', '.bar']; + const fileName = 'file.baz'; + + expect(validateFileFromAllowList(fileName, allowList)).toBe(false); + }); +}); diff --git a/spec/frontend/lib/utils/intersection_observer_spec.js b/spec/frontend/lib/utils/intersection_observer_spec.js index 71b1daffe0d..8eef403f0ae 100644 --- a/spec/frontend/lib/utils/intersection_observer_spec.js +++ b/spec/frontend/lib/utils/intersection_observer_spec.js @@ -57,7 +57,7 @@ describe('IntersectionObserver Utility', () => { ${true} | ${'IntersectionAppear'} `( 'should emit the correct event on the entry target based on the computed Intersection', - async ({ isIntersecting, event }) => { + ({ isIntersecting, event }) => { const target = document.createElement('div'); observer.addEntry({ target, isIntersecting }); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index dc4aa0ea5ed..d2591cd2328 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -3,6 +3,7 @@ import { bytesToKiB, bytesToMiB, bytesToGiB, + numberToHumanSizeSplit, numberToHumanSize, numberToMetricPrefix, sum, @@ -13,6 +14,12 @@ import { isNumeric, isPositiveInteger, } from '~/lib/utils/number_utils'; +import { + BYTES_FORMAT_BYTES, + BYTES_FORMAT_KIB, + BYTES_FORMAT_MIB, + BYTES_FORMAT_GIB, +} from '~/lib/utils/constants'; describe('Number Utils', () => { describe('formatRelevantDigits', () => { @@ -78,6 +85,28 @@ describe('Number Utils', () => { }); }); + describe('numberToHumanSizeSplit', () => { + it('should return bytes', () => { + expect(numberToHumanSizeSplit(654)).toEqual(['654', BYTES_FORMAT_BYTES]); + expect(numberToHumanSizeSplit(-654)).toEqual(['-654', BYTES_FORMAT_BYTES]); + }); + + it('should return KiB', () => { + expect(numberToHumanSizeSplit(1079)).toEqual(['1.05', BYTES_FORMAT_KIB]); + expect(numberToHumanSizeSplit(-1079)).toEqual(['-1.05', BYTES_FORMAT_KIB]); + }); + + it('should return MiB', () => { + expect(numberToHumanSizeSplit(10485764)).toEqual(['10.00', BYTES_FORMAT_MIB]); + expect(numberToHumanSizeSplit(-10485764)).toEqual(['-10.00', BYTES_FORMAT_MIB]); + }); + + it('should return GiB', () => { + expect(numberToHumanSizeSplit(10737418240)).toEqual(['10.00', BYTES_FORMAT_GIB]); + expect(numberToHumanSizeSplit(-10737418240)).toEqual(['-10.00', BYTES_FORMAT_GIB]); + }); + }); + describe('numberToHumanSize', () => { it('should return bytes', () => { expect(numberToHumanSize(654)).toEqual('654 bytes'); diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js index 63eeb54e850..096a92305dc 100644 --- a/spec/frontend/lib/utils/poll_spec.js +++ b/spec/frontend/lib/utils/poll_spec.js @@ -121,7 +121,7 @@ describe('Poll', () => { }); describe('with delayed initial request', () => { - it('delays the first request', async () => { + it('delays the first request', () => { mockServiceCall({ status: HTTP_STATUS_OK, headers: { 'poll-interval': 1 } }); const Polling = new Poll({ diff --git a/spec/frontend/lib/utils/ref_validator_spec.js b/spec/frontend/lib/utils/ref_validator_spec.js new file mode 100644 index 00000000000..7185ebf0a24 --- /dev/null +++ b/spec/frontend/lib/utils/ref_validator_spec.js @@ -0,0 +1,79 @@ +import { validateTag, validationMessages } from '~/lib/utils/ref_validator'; + +describe('~/lib/utils/ref_validator', () => { + describe('validateTag', () => { + describe.each([ + ['foo'], + ['FOO'], + ['foo/a.lockx'], + ['foo.123'], + ['foo/123'], + ['foo/bar/123'], + ['foo.bar.123'], + ['foo-bar_baz'], + ['head'], + ['"foo"-'], + ['foo@bar'], + ['\ud83e\udd8a'], + ['ünicöde'], + ['\x80}'], + ])('tag with the name "%s"', (tagName) => { + it('is valid', () => { + const result = validateTag(tagName); + expect(result.isValid).toBe(true); + expect(result.validationErrors).toEqual([]); + }); + }); + + describe.each([ + [' ', validationMessages.EmptyNameValidationMessage], + + ['refs/heads/tagName', validationMessages.DisallowedPrefixesValidationMessage], + ['/foo', validationMessages.DisallowedPrefixesValidationMessage], + ['-tagName', validationMessages.DisallowedPrefixesValidationMessage], + + ['HEAD', validationMessages.DisallowedNameValidationMessage], + ['@', validationMessages.DisallowedNameValidationMessage], + + ['tag name with spaces', validationMessages.DisallowedSubstringsValidationMessage], + ['tag\\name', validationMessages.DisallowedSubstringsValidationMessage], + ['tag^name', validationMessages.DisallowedSubstringsValidationMessage], + ['tag..name', validationMessages.DisallowedSubstringsValidationMessage], + ['..', validationMessages.DisallowedSubstringsValidationMessage], + ['tag?name', validationMessages.DisallowedSubstringsValidationMessage], + ['tag*name', validationMessages.DisallowedSubstringsValidationMessage], + ['tag[name', validationMessages.DisallowedSubstringsValidationMessage], + ['tag@{name', validationMessages.DisallowedSubstringsValidationMessage], + ['tag:name', validationMessages.DisallowedSubstringsValidationMessage], + ['tag~name', validationMessages.DisallowedSubstringsValidationMessage], + + ['/', validationMessages.DisallowedSequenceEmptyValidationMessage], + ['//', validationMessages.DisallowedSequenceEmptyValidationMessage], + ['foo//123', validationMessages.DisallowedSequenceEmptyValidationMessage], + + ['.', validationMessages.DisallowedSequencePrefixesValidationMessage], + ['/./', validationMessages.DisallowedSequencePrefixesValidationMessage], + ['./.', validationMessages.DisallowedSequencePrefixesValidationMessage], + ['.tagName', validationMessages.DisallowedSequencePrefixesValidationMessage], + ['tag/.Name', validationMessages.DisallowedSequencePrefixesValidationMessage], + ['foo/.123/bar', validationMessages.DisallowedSequencePrefixesValidationMessage], + + ['foo.', validationMessages.DisallowedSequencePostfixesValidationMessage], + ['a.lock', validationMessages.DisallowedSequencePostfixesValidationMessage], + ['foo/a.lock', validationMessages.DisallowedSequencePostfixesValidationMessage], + ['foo/a.lock/b', validationMessages.DisallowedSequencePostfixesValidationMessage], + ['foo.123.', validationMessages.DisallowedSequencePostfixesValidationMessage], + + ['foo/', validationMessages.DisallowedPostfixesValidationMessage], + + ['control-character\x7f', validationMessages.ControlCharactersValidationMessage], + ['control-character\x15', validationMessages.ControlCharactersValidationMessage], + ])('tag with name "%s"', (tagName, validationMessage) => { + it(`should be invalid with validation message "${validationMessage}"`, () => { + const result = validateTag(tagName); + expect(result.isValid).toBe(false); + expect(result.validationErrors).toContain(validationMessage); + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js new file mode 100644 index 00000000000..7bde6cc4a8e --- /dev/null +++ b/spec/frontend/lib/utils/secret_detection_spec.js @@ -0,0 +1,68 @@ +import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; + +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +const mockConfirmAction = ({ confirmed }) => { + confirmAction.mockResolvedValueOnce(confirmed); +}; + +describe('containsSensitiveToken', () => { + describe('when message does not contain sensitive tokens', () => { + const nonSensitiveMessages = [ + 'This is a normal message', + '1234567890', + '!@#$%^&*()_+', + 'https://example.com', + ]; + + it.each(nonSensitiveMessages)('returns false for message: %s', (message) => { + expect(containsSensitiveToken(message)).toBe(false); + }); + }); + + describe('when message contains sensitive tokens', () => { + const sensitiveMessages = [ + 'token: glpat-cgyKc1k_AsnEpmP-5fRL', + 'token: GlPat-abcdefghijklmnopqrstuvwxyz', + 'token: feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'https://example.com/feed?feed_token=123456789_abcdefghij', + 'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', + ]; + + it.each(sensitiveMessages)('returns true for message: %s', (message) => { + expect(containsSensitiveToken(message)).toBe(true); + }); + }); +}); + +describe('confirmSensitiveAction', () => { + afterEach(() => { + confirmAction.mockReset(); + }); + + it('should call confirmAction with correct parameters', async () => { + const prompt = 'Are you sure you want to delete this item?'; + const expectedParams = { + primaryBtnVariant: 'danger', + primaryBtnText: i18n.primaryBtnText, + }; + await confirmSensitiveAction(prompt); + + expect(confirmAction).toHaveBeenCalledWith(prompt, expectedParams); + }); + + it('should return true when confirmed is true', async () => { + mockConfirmAction({ confirmed: true }); + + const result = await confirmSensitiveAction(); + expect(result).toBe(true); + }); + + it('should return false when confirmed is false', async () => { + mockConfirmAction({ confirmed: false }); + + const result = await confirmSensitiveAction(); + expect(result).toBe(false); + }); +}); diff --git a/spec/frontend/lib/utils/sticky_spec.js b/spec/frontend/lib/utils/sticky_spec.js deleted file mode 100644 index ec9e746c838..00000000000 --- a/spec/frontend/lib/utils/sticky_spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import { setHTMLFixture } from 'helpers/fixtures'; -import { isSticky } from '~/lib/utils/sticky'; - -const TEST_OFFSET_TOP = 500; - -describe('sticky', () => { - let el; - let offsetTop; - - beforeEach(() => { - setHTMLFixture( - ` - <div class="parent"> - <div id="js-sticky"></div> - </div> - `, - ); - - offsetTop = TEST_OFFSET_TOP; - el = document.getElementById('js-sticky'); - Object.defineProperty(el, 'offsetTop', { - get() { - return offsetTop; - }, - }); - }); - - afterEach(() => { - el = null; - }); - - describe('when stuck', () => { - it('does not remove is-stuck class', () => { - isSticky(el, 0, el.offsetTop); - isSticky(el, 0, el.offsetTop); - - expect(el.classList.contains('is-stuck')).toBe(true); - }); - - it('adds is-stuck class', () => { - isSticky(el, 0, el.offsetTop); - - expect(el.classList.contains('is-stuck')).toBe(true); - }); - - it('inserts placeholder element', () => { - isSticky(el, 0, el.offsetTop, true); - - expect(document.querySelector('.sticky-placeholder')).not.toBeNull(); - }); - }); - - describe('when not stuck', () => { - it('removes is-stuck class', () => { - jest.spyOn(el.classList, 'remove'); - - isSticky(el, 0, el.offsetTop); - isSticky(el, 0, 0); - - expect(el.classList.remove).toHaveBeenCalledWith('is-stuck'); - expect(el.classList.contains('is-stuck')).toBe(false); - }); - - it('does not add is-stuck class', () => { - isSticky(el, 0, 0); - - expect(el.classList.contains('is-stuck')).toBe(false); - }); - - it('removes placeholder', () => { - isSticky(el, 0, el.offsetTop, true); - isSticky(el, 0, 0, true); - - expect(document.querySelector('.sticky-placeholder')).toBeNull(); - }); - }); -}); diff --git a/spec/frontend/lib/utils/tappable_promise_spec.js b/spec/frontend/lib/utils/tappable_promise_spec.js new file mode 100644 index 00000000000..654cd20a9de --- /dev/null +++ b/spec/frontend/lib/utils/tappable_promise_spec.js @@ -0,0 +1,63 @@ +import TappablePromise from '~/lib/utils/tappable_promise'; + +describe('TappablePromise', () => { + it('allows a promise to have a progress indicator', () => { + const pseudoProgress = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]; + const progressCallback = jest.fn(); + const promise = new TappablePromise((tap, resolve) => { + pseudoProgress.forEach(tap); + resolve('done'); + + return 'returned value'; + }); + + return promise + .tap(progressCallback) + .then((val) => { + expect(val).toBe('done'); + expect(val).not.toBe('returned value'); + + expect(progressCallback).toHaveBeenCalledTimes(pseudoProgress.length); + + pseudoProgress.forEach((progress, index) => { + expect(progressCallback).toHaveBeenNthCalledWith(index + 1, progress); + }); + }) + .catch(() => {}); + }); + + it('resolves with the value returned by the callback', () => { + const promise = new TappablePromise((tap) => { + tap(0.5); + return 'test'; + }); + + return promise + .tap((progress) => { + expect(progress).toBe(0.5); + }) + .then((value) => { + expect(value).toBe('test'); + }); + }); + + it('allows a promise to be rejected', () => { + const promise = new TappablePromise((tap, resolve, reject) => { + reject(new Error('test error')); + }); + + return promise.catch((e) => { + expect(e.message).toBe('test error'); + }); + }); + + it('rejects the promise if the callback throws an error', () => { + const promise = new TappablePromise(() => { + throw new Error('test error'); + }); + + return promise.catch((e) => { + expect(e.message).toBe('test error'); + }); + }); +}); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index 7aab1013fc0..2180ea7e6c2 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -1,12 +1,16 @@ import $ from 'jquery'; +import AxiosMockAdapter from 'axios-mock-adapter'; import { insertMarkdownText, keypressNoteText, compositionStartNoteText, compositionEndNoteText, updateTextForToolbarBtn, + resolveSelectedImage, } from '~/lib/utils/text_markdown'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import '~/lib/utils/jquery_at_who'; +import axios from '~/lib/utils/axios_utils'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; describe('init markdown', () => { @@ -14,6 +18,7 @@ describe('init markdown', () => { let textArea; let indentButton; let outdentButton; + let axiosMock; beforeAll(() => { setHTMLFixture( @@ -34,6 +39,14 @@ describe('init markdown', () => { document.execCommand = jest.fn(() => false); }); + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + }); + afterAll(() => { resetHTMLFixture(); }); @@ -707,6 +720,55 @@ describe('init markdown', () => { }); }); + describe('resolveSelectedImage', () => { + const markdownPreviewPath = '/markdown/preview'; + const imageMarkdown = '![image](/uploads/image.png)'; + const imageAbsoluteUrl = '/abs/uploads/image.png'; + + describe('when textarea cursor is positioned on an image', () => { + beforeEach(() => { + axiosMock.onPost(markdownPreviewPath, { text: imageMarkdown }).reply(HTTP_STATUS_OK, { + body: ` + <p><a href="${imageAbsoluteUrl}"><img src="${imageAbsoluteUrl}"></a></p> + `, + }); + }); + + it('returns the image absolute URL, markdown, and filename', async () => { + textArea.value = `image ${imageMarkdown}`; + textArea.setSelectionRange(8, 8); + expect(await resolveSelectedImage(textArea, markdownPreviewPath)).toEqual({ + imageURL: imageAbsoluteUrl, + imageMarkdown, + filename: 'image.png', + }); + }); + }); + + describe('when textarea cursor is not positioned on an image', () => { + it.each` + markdown | selectionRange + ${`image ${imageMarkdown}`} | ${[4, 4]} + ${`!2 (issue)`} | ${[2, 2]} + `('returns null', async ({ markdown, selectionRange }) => { + textArea.value = markdown; + textArea.setSelectionRange(...selectionRange); + expect(await resolveSelectedImage(textArea, markdownPreviewPath)).toBe(null); + }); + }); + + describe('when textarea cursor is positioned between images', () => { + it('returns null', async () => { + const position = imageMarkdown.length + 1; + + textArea.value = `${imageMarkdown}\n\n${imageMarkdown}`; + textArea.setSelectionRange(position, position); + + expect(await resolveSelectedImage(textArea, markdownPreviewPath)).toBe(null); + }); + }); + }); + describe('Source Editor', () => { let editor; diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index f2572ca0ad2..71a84d56791 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -398,4 +398,36 @@ describe('text_utility', () => { expect(textUtils.base64DecodeUnicode('8J+YgA==')).toBe('😀'); }); }); + + describe('findInvalidBranchNameCharacters', () => { + const invalidChars = [' ', '~', '^', ':', '?', '*', '[', '..', '@{', '\\', '//']; + const badBranchName = 'branch-with all these ~ ^ : ? * [ ] \\ // .. @{ } //'; + const goodBranch = 'branch-with-no-errrors'; + + it('returns an array of invalid characters in a branch name', () => { + const chars = textUtils.findInvalidBranchNameCharacters(badBranchName); + chars.forEach((char) => { + expect(invalidChars).toContain(char); + }); + }); + + it('returns an empty array with no invalid characters', () => { + expect(textUtils.findInvalidBranchNameCharacters(goodBranch)).toEqual([]); + }); + }); + + describe('humanizeBranchValidationErrors', () => { + it.each` + errors | message + ${[' ']} | ${"Can't contain spaces"} + ${['?', '//', ' ']} | ${"Can't contain spaces, ?, //"} + ${['\\', '[', '..']} | ${"Can't contain \\, [, .."} + `('returns an $message with $errors', ({ errors, message }) => { + expect(textUtils.humanizeBranchValidationErrors(errors)).toEqual(message); + }); + + it('returns an empty string with no invalid characters', () => { + expect(textUtils.humanizeBranchValidationErrors([])).toEqual(''); + }); + }); }); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 6afdab455a6..0799bc87c8c 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -45,10 +45,6 @@ describe('URL utility', () => { }); describe('webIDEUrl', () => { - afterEach(() => { - gon.relative_url_root = ''; - }); - it('escapes special characters', () => { expect(urlUtils.webIDEUrl('/gitlab-org/gitlab-#-foss/merge_requests/1')).toBe( '/-/ide/project/gitlab-org/gitlab-%23-foss/merge_requests/1', @@ -505,10 +501,6 @@ describe('URL utility', () => { gon.gitlab_url = gitlabUrl; }); - afterEach(() => { - gon.gitlab_url = ''; - }); - it.each` url | urlType | external ${'/gitlab-org/gitlab-test/-/issues/2'} | ${'relative'} | ${false} @@ -796,18 +788,6 @@ describe('URL utility', () => { }); }); - describe('stripFinalUrlSegment', () => { - it.each` - path | expected - ${'http://fake.domain/twitter/typeahead-js/-/tags/v0.11.0'} | ${'http://fake.domain/twitter/typeahead-js/-/tags/'} - ${'http://fake.domain/bar/cool/-/nested/content'} | ${'http://fake.domain/bar/cool/-/nested/'} - ${'http://fake.domain/bar/cool?q="search"'} | ${'http://fake.domain/bar/'} - ${'http://fake.domain/bar/cool#link-to-something'} | ${'http://fake.domain/bar/'} - `('stripFinalUrlSegment $path => $expected', ({ path, expected }) => { - expect(urlUtils.stripFinalUrlSegment(path)).toBe(expected); - }); - }); - describe('escapeFileUrl', () => { it('encodes URL excluding the slashes', () => { expect(urlUtils.escapeFileUrl('/foo-bar/file.md')).toBe('/foo-bar/file.md'); diff --git a/spec/frontend/lib/utils/vuex_module_mappers_spec.js b/spec/frontend/lib/utils/vuex_module_mappers_spec.js index d25a692dfea..abd5095c1d2 100644 --- a/spec/frontend/lib/utils/vuex_module_mappers_spec.js +++ b/spec/frontend/lib/utils/vuex_module_mappers_spec.js @@ -96,10 +96,6 @@ describe('~/lib/utils/vuex_module_mappers', () => { }); }); - afterEach(() => { - wrapper.destroy(); - }); - describe('from module defined by prop', () => { it('maps state', () => { expect(getMappedState()).toEqual({ diff --git a/spec/frontend/lib/utils/web_ide_navigator_spec.js b/spec/frontend/lib/utils/web_ide_navigator_spec.js new file mode 100644 index 00000000000..0f5cd09d50e --- /dev/null +++ b/spec/frontend/lib/utils/web_ide_navigator_spec.js @@ -0,0 +1,38 @@ +import { visitUrl, webIDEUrl } from '~/lib/utils/url_utility'; +import { openWebIDE } from '~/lib/utils/web_ide_navigator'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + webIDEUrl: jest.fn().mockImplementation((path) => `/-/ide/projects${path}`), +})); + +describe('openWebIDE', () => { + it('when called without projectPath throws TypeError and does not call visitUrl', () => { + expect(() => { + openWebIDE(); + }).toThrow(new TypeError('projectPath parameter is required')); + expect(visitUrl).not.toHaveBeenCalled(); + }); + + it('when called with projectPath and without fileName calls visitUrl with correct path', () => { + const params = { projectPath: 'project-path' }; + const expectedNonIDEPath = `/${params.projectPath}/edit/main/-/`; + const expectedIDEPath = `/-/ide/projects${expectedNonIDEPath}`; + + openWebIDE(params.projectPath); + + expect(webIDEUrl).toHaveBeenCalledWith(expectedNonIDEPath); + expect(visitUrl).toHaveBeenCalledWith(expectedIDEPath); + }); + + it('when called with projectPath and fileName calls visitUrl with correct path', () => { + const params = { projectPath: 'project-path', fileName: 'README' }; + const expectedNonIDEPath = `/${params.projectPath}/edit/main/-/${params.fileName}/`; + const expectedIDEPath = `/-/ide/projects${expectedNonIDEPath}`; + + openWebIDE(params.projectPath, params.fileName); + + expect(webIDEUrl).toHaveBeenCalledWith(expectedNonIDEPath); + expect(visitUrl).toHaveBeenCalledWith(expectedIDEPath); + }); +}); |