diff --git a/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue index 37e780a6c948a4a408ea23380a9125b70cc1f65a..c35627be644c615c67d8f91161d71b815bb30460 100644 --- a/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue +++ b/app/assets/javascripts/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue @@ -1,7 +1,7 @@ @@ -128,11 +129,10 @@ export default {
{{
           instructions.installInstructions
         }}
- @@ -141,11 +141,10 @@ export default {
{{
           registerInstructionsWithToken
         }}
- diff --git a/app/assets/javascripts/lib/utils/copy_to_clipboard.js b/app/assets/javascripts/lib/utils/copy_to_clipboard.js new file mode 100644 index 0000000000000000000000000000000000000000..d1789a350cc9a88fab78b80730bd0f388212c042 --- /dev/null +++ b/app/assets/javascripts/lib/utils/copy_to_clipboard.js @@ -0,0 +1,39 @@ +/** + * Programmatically copies a text string to the clipboard. Attempts to copy in both secure and non-secure contexts. + * + * Accepts a container element. This helps ensure the text can get copied to the clipboard correctly in non-secure + * environments, the container should be active (such as a button in a modal) to ensure the content can be copied. + * + * @param {String} text - Text to copy + * @param {HTMLElement} container - Container to dummy textarea (for fallback behavior). + */ +export const copyToClipboard = (text, container = document.body) => { + // First, try a simple clipboard.writeText (works on https and localhost) + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + + // Second, try execCommand to copy from a dynamically created invisible textarea (for http and older browsers) + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; // eslint-disable-line @gitlab/require-i18n-strings + textarea.style.top = '0'; + textarea.setAttribute('readonly', ''); // prevent keyboard popup on mobile + + // textarea must be in document to be selectable, but we add it to the button so it works in modals + container.appendChild(textarea); + + textarea.select(); // for Safari + textarea.setSelectionRange(0, textarea.value.length); // for mobile devices + + try { + const done = document.execCommand('copy'); + container.removeChild(textarea); + // eslint-disable-next-line @gitlab/require-i18n-strings + return done ? Promise.resolve() : Promise.reject(new Error('Copy command failed')); + } catch (err) { + container.removeChild(textarea); + return Promise.reject(err); + } +}; diff --git a/app/assets/javascripts/vue_shared/components/simple_copy_button.vue b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue new file mode 100644 index 0000000000000000000000000000000000000000..e1385d9c038ae37f8c646f3affda9755f421372c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/simple_copy_button.vue @@ -0,0 +1,138 @@ + + diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3cf0fd899af59092cab814eabffaa0951668841b..43f01b4bdb5f25c80a002cacf47dd3244d9969b4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19409,6 +19409,9 @@ msgstr "" msgid "Copied reference." msgstr "" +msgid "Copied to clipboard." +msgstr "" + msgid "Copy" msgstr "" diff --git a/spec/frontend/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions_spec.js b/spec/frontend/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions_spec.js index 3cd1a7f47e8901b357d8b5b095c8bde4837b8f20..dad521d6bbb7290de1e09f632900057027b69eb5 100644 --- a/spec/frontend/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions_spec.js +++ b/spec/frontend/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions_spec.js @@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import SimpleCopyButton from '~/vue_shared/components/simple_copy_button.vue'; import getRunnerSetupInstructionsQuery from '~/ci/runner/components/registration/runner_instructions/graphql/get_runner_setup.query.graphql'; import RunnerCliInstructions from '~/ci/runner/components/registration/runner_instructions/instructions/runner_cli_instructions.vue'; @@ -33,6 +34,7 @@ describe('RunnerCliInstructions component', () => { const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); const findArchitectureDropdownItems = () => wrapper.findAllComponents(GlListboxItem); + const findSimpleCopyButtons = () => wrapper.findAllComponents(SimpleCopyButton); const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button'); const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); const findRegisterCommand = () => wrapper.findByTestId('register-command'); @@ -89,14 +91,23 @@ describe('RunnerCliInstructions component', () => { const instructions = findBinaryInstructions().text(); expect(instructions).toBe(installInstructions.trim()); + + expect(findSimpleCopyButtons().at(0).props()).toMatchObject({ + title: 'Copy instructions', + text: installInstructions, + }); }); it('register command is shown with a replaced token', () => { - const command = findRegisterCommand().text(); + const command = + 'sudo gitlab-runner register --url http://localhost/ --registration-token MY_TOKEN'; - expect(command).toBe( - 'sudo gitlab-runner register --url http://localhost/ --registration-token MY_TOKEN', - ); + expect(findRegisterCommand().text()).toBe(command); + + expect(findSimpleCopyButtons().at(1).props()).toMatchObject({ + title: 'Copy command', + text: command, + }); }); it('architecture download link is shown', () => { diff --git a/spec/frontend/lib/utils/copy_to_clipboard_spec.js b/spec/frontend/lib/utils/copy_to_clipboard_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c8df0e8bd6020800267e32b86ed8555fb068dc7f --- /dev/null +++ b/spec/frontend/lib/utils/copy_to_clipboard_spec.js @@ -0,0 +1,154 @@ +import { copyToClipboard } from '~/lib/utils/copy_to_clipboard'; + +describe('copyToClipboard', () => { + let mockWriteText; + + beforeEach(() => { + document.execCommand = jest.fn().mockReturnValue(true); + mockWriteText = jest.spyOn(navigator.clipboard, 'writeText'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('in secure context (HTTPS/localhost)', () => { + beforeEach(() => { + window.isSecureContext = true; + }); + + it('should use navigator.clipboard.writeText', async () => { + mockWriteText.mockResolvedValue(); + + await copyToClipboard('test text'); + + expect(mockWriteText).toHaveBeenCalledWith('test text'); + expect(document.execCommand).not.toHaveBeenCalled(); + }); + + it('should reject when navigator.clipboard.writeText fails', async () => { + const error = new Error('Clipboard write failed'); + mockWriteText.mockRejectedValue(error); + + await expect(copyToClipboard('test text')).rejects.toThrow('Clipboard write failed'); + }); + }); + + describe('in non-secure context (HTTP)', () => { + beforeEach(() => { + window.isSecureContext = false; + document.execCommand.mockReturnValue(true); + }); + + it('should use execCommand', async () => { + await copyToClipboard('test text'); + + expect(mockWriteText).not.toHaveBeenCalled(); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); + + it('should create an invisible textarea', async () => { + jest.spyOn(document, 'createElement'); + + await copyToClipboard('test text'); + + expect(document.createElement).toHaveBeenCalledWith('textarea'); + + const textarea = document.createElement.mock.results[0].value; + + expect(textarea.value).toBe('test text'); + expect(textarea.style).toMatchObject({ + position: 'absolute', + left: '-9999px', + top: '0px', + }); + expect(textarea.getAttribute('readonly')).toBe(''); + }); + + it('should append textarea to default container (document.body)', async () => { + const appendChildSpy = jest.spyOn(document.body, 'appendChild'); + const removeChildSpy = jest.spyOn(document.body, 'removeChild'); + + await copyToClipboard('test text'); + + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it('should append textarea to custom container', async () => { + const container = document.createElement('div'); + + const appendChildSpy = jest.spyOn(container, 'appendChild'); + const removeChildSpy = jest.spyOn(container, 'removeChild'); + + await copyToClipboard('test text', container); + + expect(appendChildSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it('should call select and setSelectionRange on textarea', async () => { + const textarea = document.createElement('textarea'); + jest.spyOn(textarea, 'select'); + jest.spyOn(textarea, 'setSelectionRange'); + + jest.spyOn(document, 'createElement').mockImplementation(() => { + return textarea; + }); + + await copyToClipboard('test text'); + + expect(textarea.select).toHaveBeenCalled(); + expect(textarea.setSelectionRange).toHaveBeenCalledWith(0, 9); // 'test text'.length = 9 + }); + + it('should resolve promise when execCommand returns true', async () => { + document.execCommand.mockReturnValue(true); + + await expect(copyToClipboard('test text')).resolves.toBeUndefined(); + }); + + it('should reject promise when execCommand returns false', async () => { + document.execCommand.mockReturnValue(false); + + await expect(copyToClipboard('test text')).rejects.toEqual(new Error('Copy command failed')); + }); + + it('should reject promise when execCommand throws an error', async () => { + const error = new Error('execCommand failed'); + document.execCommand.mockImplementation(() => { + throw error; + }); + + await expect(copyToClipboard('test text')).rejects.toThrow('execCommand failed'); + }); + + it('should remove textarea from DOM even when execCommand fails', async () => { + document.execCommand.mockReturnValue(false); + const removeChildSpy = jest.spyOn(document.body, 'removeChild'); + + try { + await copyToClipboard('test text'); + } catch (err) { + // Expected to reject + } + + expect(removeChildSpy).toHaveBeenCalled(); + }); + + it('should remove textarea from DOM even when execCommand throws', async () => { + document.execCommand.mockImplementation(() => { + throw new Error('Failed'); + }); + const removeChildSpy = jest.spyOn(document.body, 'removeChild'); + + try { + await copyToClipboard('test text'); + } catch (err) { + // Expected to reject + } + + expect(removeChildSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/simple_copy_button_spec.js b/spec/frontend/vue_shared/components/simple_copy_button_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6d56dc2aaa000c2f2f03951eb107603a437758a8 --- /dev/null +++ b/spec/frontend/vue_shared/components/simple_copy_button_spec.js @@ -0,0 +1,180 @@ +import { GlButton } from '@gitlab/ui'; +import { mount, createWrapper as vtuCreateWrapper } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { copyToClipboard } from '~/lib/utils/copy_to_clipboard'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; + +import SimpleCopyButton from '~/vue_shared/components/simple_copy_button.vue'; + +jest.mock('~/lib/utils/copy_to_clipboard'); +jest.mock('~/sentry/sentry_browser_wrapper'); + +describe('clipboard button', () => { + let wrapper; + let rootWrapper; + let mockToastShow; + + const createWrapper = ({ props, ...options } = {}) => { + wrapper = mount(SimpleCopyButton, { + propsData: { + text: 'copy me', + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + mocks: { + $toast: { show: mockToastShow }, + }, + ...options, + }); + + rootWrapper = vtuCreateWrapper(wrapper.vm.$root); + }; + + const findButton = () => wrapper.findComponent(GlButton); + const getTooltip = () => getBinding(findButton().element, 'gl-tooltip'); + + const clickButton = async () => { + findButton().vm.$emit('click'); + await waitForPromises(); + }; + + beforeEach(() => { + mockToastShow = jest.fn(); + copyToClipboard.mockResolvedValue(); + }); + + describe('default options', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders a button to copy', () => { + expect(findButton().props('category')).toBe('secondary'); + expect(findButton().props('size')).toBe('medium'); + expect(findButton().props('variant')).toBe('default'); + expect(findButton().props('icon')).toBe('copy-to-clipboard'); + + expect(findButton().attributes('aria-live')).toBe('polite'); + expect(findButton().attributes('aria-label')).toBe('Copy'); + }); + + it('configures tooltip', () => { + expect(getTooltip()).toMatchObject({ + value: { placement: 'top', title: 'Copy' }, + }); + }); + + describe('when clicked', () => { + beforeEach(async () => { + await clickButton(); + }); + + it('copies', () => { + expect(copyToClipboard).toHaveBeenCalledWith('copy me', wrapper.element); + }); + + it('shows toast', () => { + expect(mockToastShow).toHaveBeenCalledWith('Copied to clipboard.'); + }); + + it('emits "copied" event', () => { + expect(wrapper.emitted('copied')).toEqual([[]]); + }); + }); + + describe('when on mouseout', () => { + beforeEach(() => { + findButton().vm.$emit('mouseout'); + }); + + it('hides tooltip', () => { + expect(rootWrapper.emitted('bv::hide::tooltip')).toEqual([[wrapper.element.id]]); + }); + }); + }); + + describe('customization', () => { + it('renders a button to copy with other options', () => { + createWrapper({ + props: { + category: 'tertiary', + size: 'small', + variant: 'confirm', + icon: 'pencil', + ariaLabel: 'My aria label', + title: 'My title', + }, + }); + + expect(findButton().props('category')).toBe('tertiary'); + expect(findButton().props('size')).toBe('small'); + expect(findButton().props('variant')).toBe('confirm'); + expect(findButton().props('icon')).toBe('pencil'); + + expect(findButton().attributes('aria-live')).toBe('polite'); + expect(findButton().attributes('aria-label')).toBe('My aria label'); + + expect(getTooltip()).toMatchObject({ + value: { title: 'My title' }, + }); + }); + + it('shows another toast message', async () => { + createWrapper({ + props: { toastMessage: 'Copied! Yey!' }, + }); + await clickButton(); + + expect(mockToastShow).toHaveBeenCalledWith('Copied! Yey!'); + }); + + it('shows no toast message', async () => { + createWrapper({ + props: { toastMessage: '' }, + }); + await clickButton(); + + expect(mockToastShow).not.toHaveBeenCalled(); + }); + + it('shows no toast message when the type is not a string', async () => { + createWrapper({ + props: { toastMessage: true }, + }); + await clickButton(); + + expect(mockToastShow).not.toHaveBeenCalled(); + }); + }); + + describe('on error', () => { + beforeEach(() => { + createWrapper(); + + copyToClipboard.mockRejectedValue(new Error('error copying')); + }); + + describe('when clicked', () => { + beforeEach(async () => { + await clickButton(); + }); + + it('tries to copy', () => { + expect(copyToClipboard).toHaveBeenCalledWith('copy me', wrapper.element); + }); + + it('does not shows toast', () => { + expect(mockToastShow).not.toHaveBeenCalled(); + }); + + it('emits "error" event and reports to sentry', () => { + expect(wrapper.emitted('error')).toEqual([[new Error('error copying')]]); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('error copying')); + }); + }); + }); +});