Skip to content

Commit ece7031

Browse files
committed
feat: add post-copy event handlers to CopyToClipboard (#4040)
1 parent 8568662 commit ece7031

File tree

4 files changed

+425
-0
lines changed

4 files changed

+425
-0
lines changed
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React from 'react';
4+
import { act, render, waitFor } from '@testing-library/react';
5+
6+
import CopyToClipboard from '../../../lib/components/copy-to-clipboard';
7+
import createWrapper from '../../../lib/components/test-utils/dom';
8+
9+
// Representative test cases covering various string types
10+
const testCases = [
11+
'simple text',
12+
'text with spaces and punctuation!',
13+
'12345',
14+
'special chars: @#$%^&*()',
15+
'unicode: 你好世界 🎉',
16+
'multiline\ntext\nhere',
17+
'<html>tags</html>',
18+
' whitespace ',
19+
'a', // single character
20+
'a'.repeat(1000), // long string
21+
];
22+
23+
/**
24+
* **Feature: copy-to-clipboard-oncopy-handler, Property 1: Successful copy invokes callback with correct text**
25+
*
26+
* *For any* text value passed to `textToCopy`, when the clipboard write operation succeeds,
27+
* the `onCopySuccess` callback should be invoked exactly once with an event detail object
28+
* where `detail.text` equals the `textToCopy` value.
29+
*
30+
* **Validates: Requirements 1.1, 1.2, 2.2**
31+
*/
32+
describe('CopyToClipboard Property Tests', () => {
33+
const originalNavigatorClipboard = global.navigator.clipboard;
34+
const originalNavigatorPermissions = global.navigator.permissions;
35+
36+
beforeEach(() => {
37+
Object.assign(global.navigator, {
38+
clipboard: {
39+
writeText: jest.fn().mockResolvedValue(undefined),
40+
},
41+
permissions: {
42+
query: jest.fn().mockResolvedValue({ state: 'granted' }),
43+
},
44+
});
45+
});
46+
47+
afterEach(() => {
48+
Object.assign(global.navigator, {
49+
clipboard: originalNavigatorClipboard,
50+
permissions: originalNavigatorPermissions,
51+
});
52+
});
53+
54+
test.each(testCases)('Property 1: Successful copy invokes callback with correct text - %s', async textToCopy => {
55+
const onCopySuccess = jest.fn();
56+
57+
const { container } = render(
58+
<CopyToClipboard
59+
textToCopy={textToCopy}
60+
copyButtonText="Copy"
61+
copySuccessText="Copied"
62+
copyErrorText="Failed"
63+
onCopySuccess={onCopySuccess}
64+
/>
65+
);
66+
67+
const wrapper = createWrapper(container).findCopyToClipboard()!;
68+
69+
await act(() => {
70+
wrapper.findCopyButton().click();
71+
});
72+
73+
await waitFor(() => {
74+
// Callback should be invoked exactly once
75+
expect(onCopySuccess).toHaveBeenCalledTimes(1);
76+
// Callback should receive the correct text in detail object
77+
expect(onCopySuccess).toHaveBeenCalledWith(
78+
expect.objectContaining({
79+
detail: { text: textToCopy },
80+
})
81+
);
82+
});
83+
});
84+
85+
/**
86+
* **Feature: copy-to-clipboard-oncopy-handler, Property 2: Failed copy invokes failure callback with correct text**
87+
*
88+
* *For any* text value passed to `textToCopy`, when the clipboard write operation fails,
89+
* the `onCopyFailure` callback should be invoked exactly once with an event detail object
90+
* where `detail.text` equals the `textToCopy` value.
91+
*
92+
* **Validates: Requirements 3.1, 3.2, 4.2**
93+
*/
94+
test.each(testCases)('Property 2: Failed copy invokes failure callback with correct text - %s', async textToCopy => {
95+
// Mock clipboard to fail
96+
Object.assign(global.navigator, {
97+
clipboard: {
98+
writeText: jest.fn().mockRejectedValue(new Error('Copy failed')),
99+
},
100+
});
101+
102+
const onCopyFailure = jest.fn();
103+
104+
const { container } = render(
105+
<CopyToClipboard
106+
textToCopy={textToCopy}
107+
copyButtonText="Copy"
108+
copySuccessText="Copied"
109+
copyErrorText="Failed"
110+
onCopyFailure={onCopyFailure}
111+
/>
112+
);
113+
114+
const wrapper = createWrapper(container).findCopyToClipboard()!;
115+
116+
await act(() => {
117+
wrapper.findCopyButton().click();
118+
});
119+
120+
await waitFor(() => {
121+
// Callback should be invoked exactly once
122+
expect(onCopyFailure).toHaveBeenCalledTimes(1);
123+
// Callback should receive the correct text in detail object
124+
expect(onCopyFailure).toHaveBeenCalledWith(
125+
expect.objectContaining({
126+
detail: { text: textToCopy },
127+
})
128+
);
129+
});
130+
});
131+
132+
/**
133+
* **Feature: copy-to-clipboard-oncopy-handler, Property 3: Success and failure callbacks are mutually exclusive**
134+
*
135+
* *For any* copy operation, exactly one of `onCopySuccess` or `onCopyFailure` should be invoked,
136+
* never both and never neither (when both callbacks are provided).
137+
*
138+
* **Validates: Requirements 1.3, 3.3**
139+
*/
140+
describe('Property 3: Success and failure callbacks are mutually exclusive', () => {
141+
test.each(testCases)('when copy succeeds - %s', async textToCopy => {
142+
Object.assign(global.navigator, {
143+
clipboard: {
144+
writeText: jest.fn().mockResolvedValue(undefined),
145+
},
146+
});
147+
148+
const onCopySuccess = jest.fn();
149+
const onCopyFailure = jest.fn();
150+
151+
const { container } = render(
152+
<CopyToClipboard
153+
textToCopy={textToCopy}
154+
copyButtonText="Copy"
155+
copySuccessText="Copied"
156+
copyErrorText="Failed"
157+
onCopySuccess={onCopySuccess}
158+
onCopyFailure={onCopyFailure}
159+
/>
160+
);
161+
162+
const wrapper = createWrapper(container).findCopyToClipboard()!;
163+
164+
await act(() => {
165+
wrapper.findCopyButton().click();
166+
});
167+
168+
await waitFor(() => {
169+
const successCalls = onCopySuccess.mock.calls.length;
170+
const failureCalls = onCopyFailure.mock.calls.length;
171+
172+
expect(successCalls + failureCalls).toBe(1);
173+
expect(onCopySuccess).toHaveBeenCalledTimes(1);
174+
expect(onCopyFailure).not.toHaveBeenCalled();
175+
});
176+
});
177+
178+
test.each(testCases)('when copy fails - %s', async textToCopy => {
179+
Object.assign(global.navigator, {
180+
clipboard: {
181+
writeText: jest.fn().mockRejectedValue(new Error('Copy failed')),
182+
},
183+
});
184+
185+
const onCopySuccess = jest.fn();
186+
const onCopyFailure = jest.fn();
187+
188+
const { container } = render(
189+
<CopyToClipboard
190+
textToCopy={textToCopy}
191+
copyButtonText="Copy"
192+
copySuccessText="Copied"
193+
copyErrorText="Failed"
194+
onCopySuccess={onCopySuccess}
195+
onCopyFailure={onCopyFailure}
196+
/>
197+
);
198+
199+
const wrapper = createWrapper(container).findCopyToClipboard()!;
200+
201+
await act(() => {
202+
wrapper.findCopyButton().click();
203+
});
204+
205+
await waitFor(() => {
206+
const successCalls = onCopySuccess.mock.calls.length;
207+
const failureCalls = onCopyFailure.mock.calls.length;
208+
209+
expect(successCalls + failureCalls).toBe(1);
210+
expect(onCopyFailure).toHaveBeenCalledTimes(1);
211+
expect(onCopySuccess).not.toHaveBeenCalled();
212+
});
213+
});
214+
});
215+
216+
/**
217+
* **Feature: copy-to-clipboard-oncopy-handler, Property 4: Disabled component invokes neither callback**
218+
*
219+
* *For any* disabled CopyToClipboard component, clicking the copy button should not invoke
220+
* either `onCopySuccess` or `onCopyFailure` callbacks.
221+
*
222+
* **Validates: Requirements 2.3, 4.3**
223+
*/
224+
test.each(testCases)('Property 4: Disabled component invokes neither callback - %s', async textToCopy => {
225+
const onCopySuccess = jest.fn();
226+
const onCopyFailure = jest.fn();
227+
228+
const { container } = render(
229+
<CopyToClipboard
230+
textToCopy={textToCopy}
231+
copyButtonText="Copy"
232+
copySuccessText="Copied"
233+
copyErrorText="Failed"
234+
onCopySuccess={onCopySuccess}
235+
onCopyFailure={onCopyFailure}
236+
disabled={true}
237+
/>
238+
);
239+
240+
const wrapper = createWrapper(container).findCopyToClipboard()!;
241+
242+
await act(() => {
243+
wrapper.findCopyButton().click();
244+
});
245+
246+
// Neither callback should be invoked for disabled component
247+
expect(onCopySuccess).not.toHaveBeenCalled();
248+
expect(onCopyFailure).not.toHaveBeenCalled();
249+
});
250+
});

0 commit comments

Comments
 (0)