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'));
+ });
+ });
+ });
+});