1
- import { act , renderHook } from "@testing-library/react" ;
1
+ /**
2
+ * @file The test setup for this file is a little funky because of how React
3
+ * Testing Library works.
4
+ *
5
+ * When you call user.setup to make a new user session, it will make a mock
6
+ * clipboard instance that will always succeed. It also can't be removed after
7
+ * it's been added, and it will persist across test cases. This actually makes
8
+ * testing useClipboard properly impossible because any call to user.setup
9
+ * immediately pollutes the tests with false negatives. Even if something should
10
+ * fail, it won't.
11
+ */
12
+ import { act , renderHook , screen } from "@testing-library/react" ;
2
13
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar" ;
3
14
import { ThemeProvider } from "contexts/ThemeProvider" ;
4
15
import {
5
16
type UseClipboardInput ,
6
17
type UseClipboardResult ,
18
+ COPY_FAILED_MESSAGE ,
7
19
useClipboard ,
20
+ HTTP_FALLBACK_DATA_ID ,
8
21
} from "./useClipboard" ;
9
22
10
- describe ( useClipboard . name , ( ) => {
11
- describe ( "HTTP (non-secure) connections" , ( ) => {
12
- scheduleClipboardTests ( { isHttps : false } ) ;
13
- } ) ;
14
-
15
- describe ( "HTTPS (secure/default) connections" , ( ) => {
16
- scheduleClipboardTests ( { isHttps : true } ) ;
17
- } ) ;
18
- } ) ;
23
+ // Need to mock console.error because we deliberately need to trigger errors in
24
+ // the code to assert that it can recover from them, but we also don't want them
25
+ // logged if they're expected
26
+ const originalConsoleError = console . error ;
19
27
20
- /**
21
- * @file This is a very weird test setup.
22
- *
23
- * There are two main things that it's fighting against to insure that the
24
- * clipboard functionality is working as expected:
25
- * 1. userEvent.setup's default global behavior
26
- * 2. The fact that we need to reuse the same set of test cases for two separate
27
- * contexts (secure and insecure), each with their own version of global
28
- * state.
29
- *
30
- * The goal of this file is to provide a shared set of test behavior that can
31
- * be imported into two separate test files (one for HTTP, one for HTTPS),
32
- * without any risk of global state conflicts.
33
- *
34
- * ---
35
- * For (1), normally you could call userEvent.setup to enable clipboard mocking,
36
- * but userEvent doesn't expose a teardown function. It also modifies the global
37
- * scope for the whole test file, so enabling just one userEvent session will
38
- * make a mock clipboard exist for all other tests, even though you didn't tell
39
- * them to set up a session. The mock also assumes that the clipboard API will
40
- * always be available, which is not true on HTTP-only connections
41
- *
42
- * Since these tests need to split hairs and differentiate between HTTP and
43
- * HTTPS connections, setting up a single userEvent is disastrous. It will make
44
- * all the tests pass, even if they shouldn't. Have to avoid that by creating a
45
- * custom clipboard mock.
46
- *
47
- * ---
48
- * For (2), we're fighting against Jest's default behavior, which is to treat
49
- * the test file as the main boundary for test environments, with each test case
50
- * able to run in parallel. That works if you have one single global state, but
51
- * we need two separate versions of the global state, while repeating the exact
52
- * same test cases for each one.
53
- *
54
- * If both tests were to be placed in the same file, Jest would not isolate them
55
- * and would let their setup steps interfere with each other. This leads to one
56
- * of two things:
57
- * 1. One of the global mocks overrides the other, making it so that one
58
- * connection type always fails
59
- * 2. The two just happen not to conflict each other, through some convoluted
60
- * order of operations involving closure, but you have no idea why the code
61
- * is working, and it's impossible to debug.
62
- */
63
- type MockClipboardEscapeHatches = Readonly < {
64
- getMockText : ( ) => string ;
65
- setMockText : ( newText : string ) => void ;
66
- simulateFailure : boolean ;
67
- setSimulateFailure : ( failureMode : boolean ) => void ;
28
+ type SetupMockClipboardResult = Readonly < {
29
+ mockClipboard : Clipboard ;
30
+ mockExecCommand : typeof global . document . execCommand ;
31
+ getClipboardText : ( ) => string ;
32
+ setSimulateFailure : ( shouldFail : boolean ) => void ;
33
+ resetMockClipboardState : ( ) => void ;
68
34
} > ;
69
35
70
- type MockClipboard = Readonly < Clipboard & MockClipboardEscapeHatches > ;
71
- function makeMockClipboard ( isSecureContext : boolean ) : MockClipboard {
72
- let mockClipboardValue = "" ;
73
- let shouldFail = false ;
74
-
75
- return {
76
- get simulateFailure ( ) {
77
- return shouldFail ;
78
- } ,
79
- setSimulateFailure : ( value ) => {
80
- shouldFail = value ;
81
- } ,
36
+ function setupMockClipboard ( isSecure : boolean ) : SetupMockClipboardResult {
37
+ let mockClipboardText = "" ;
38
+ let shouldSimulateFailure = false ;
82
39
40
+ const mockClipboard : Clipboard = {
83
41
readText : async ( ) => {
84
- if ( shouldFail ) {
85
- throw new Error ( "Clipboard deliberately failed" ) ;
86
- }
87
-
88
- if ( ! isSecureContext ) {
42
+ if ( ! isSecure ) {
89
43
throw new Error (
90
- "Trying to read from clipboard outside secure context! " ,
44
+ "Not allowed to access clipboard outside of secure contexts " ,
91
45
) ;
92
46
}
93
47
94
- return mockClipboardValue ;
48
+ if ( shouldSimulateFailure ) {
49
+ throw new Error ( "Failed to read from clipboard" ) ;
50
+ }
51
+
52
+ return mockClipboardText ;
95
53
} ,
54
+
96
55
writeText : async ( newText ) => {
97
- if ( shouldFail ) {
98
- throw new Error ( "Clipboard deliberately failed" ) ;
56
+ if ( ! isSecure ) {
57
+ throw new Error (
58
+ "Not allowed to access clipboard outside of secure contexts" ,
59
+ ) ;
99
60
}
100
61
101
- if ( ! isSecureContext ) {
102
- throw new Error ( "Trying to write to clipboard outside secure context! " ) ;
62
+ if ( shouldSimulateFailure ) {
63
+ throw new Error ( "Failed to write to clipboard" ) ;
103
64
}
104
65
105
- mockClipboardValue = newText ;
106
- } ,
107
-
108
- getMockText : ( ) => mockClipboardValue ,
109
- setMockText : ( newText ) => {
110
- mockClipboardValue = newText ;
66
+ mockClipboardText = newText ;
111
67
} ,
112
68
69
+ // Don't need these other methods for any of the tests; read and write are
70
+ // both synchronous and slower than the promise-based methods, so ideally
71
+ // we won't ever need to call them in the hook logic
113
72
addEventListener : jest . fn ( ) ,
114
73
removeEventListener : jest . fn ( ) ,
115
74
dispatchEvent : jest . fn ( ) ,
116
75
read : jest . fn ( ) ,
117
76
write : jest . fn ( ) ,
118
77
} ;
78
+
79
+ return {
80
+ mockClipboard,
81
+ getClipboardText : ( ) => mockClipboardText ,
82
+ setSimulateFailure : ( newShouldFailValue ) => {
83
+ shouldSimulateFailure = newShouldFailValue ;
84
+ } ,
85
+ resetMockClipboardState : ( ) => {
86
+ shouldSimulateFailure = false ;
87
+ mockClipboardText = "" ;
88
+ } ,
89
+ mockExecCommand : ( commandId ) => {
90
+ if ( commandId !== "copy" ) {
91
+ return false ;
92
+ }
93
+
94
+ if ( shouldSimulateFailure ) {
95
+ throw new Error ( "Failed to execute command 'copy'" ) ;
96
+ }
97
+
98
+ const dummyInput = document . querySelector (
99
+ `input[data-testid=${ HTTP_FALLBACK_DATA_ID } ]` ,
100
+ ) ;
101
+
102
+ const inputIsFocused =
103
+ dummyInput instanceof HTMLInputElement &&
104
+ document . activeElement === dummyInput ;
105
+
106
+ let copySuccessful = false ;
107
+ if ( inputIsFocused ) {
108
+ mockClipboardText = dummyInput . value ;
109
+ copySuccessful = true ;
110
+ }
111
+
112
+ return copySuccessful ;
113
+ } ,
114
+ } ;
119
115
}
120
116
121
- function renderUseClipboard ( inputs : UseClipboardInput ) {
122
- return renderHook < UseClipboardResult , UseClipboardInput > (
117
+ function renderUseClipboard < TInput extends UseClipboardInput > ( inputs : TInput ) {
118
+ return renderHook < UseClipboardResult , TInput > (
123
119
( props ) => useClipboard ( props ) ,
124
120
{
125
121
initialProps : inputs ,
126
122
wrapper : ( { children } ) => (
123
+ // Need ThemeProvider because GlobalSnackbar uses theme
127
124
< ThemeProvider >
128
125
{ children }
129
126
< GlobalSnackbar />
@@ -133,88 +130,132 @@ function renderUseClipboard(inputs: UseClipboardInput) {
133
130
) ;
134
131
}
135
132
136
- type ScheduleConfig = Readonly < { isHttps : boolean } > ;
133
+ type RenderResult = ReturnType < typeof renderUseClipboard > [ "result" ] ;
137
134
138
- export function scheduleClipboardTests ( { isHttps } : ScheduleConfig ) {
139
- const mockClipboardInstance = makeMockClipboard ( isHttps ) ;
140
- const originalNavigator = window . navigator ;
135
+ // execCommand is the workaround for copying text to the clipboard on HTTP-only
136
+ // connections
137
+ const originalExecCommand = global . document . execCommand ;
138
+ const originalNavigator = window . navigator ;
139
+
140
+ // Not a big fan of describe.each most of the time, but since we need to test
141
+ // the exact same test cases against different inputs, and we want them to run
142
+ // as sequentially as possible to minimize flakes, they make sense here
143
+ const secureContextValues : readonly boolean [ ] = [ true , false ] ;
144
+ describe . each ( secureContextValues ) ( "useClipboard - secure: %j" , ( isSecure ) => {
145
+ const {
146
+ mockClipboard,
147
+ mockExecCommand,
148
+ getClipboardText,
149
+ setSimulateFailure,
150
+ resetMockClipboardState,
151
+ } = setupMockClipboard ( isSecure ) ;
141
152
142
153
beforeEach ( ( ) => {
143
154
jest . useFakeTimers ( ) ;
155
+
156
+ // Can't use jest.spyOn here because there's no guarantee that the mock
157
+ // browser environment actually implements execCommand. Trying to spy on an
158
+ // undefined value will throw an error
159
+ global . document . execCommand = mockExecCommand ;
160
+
144
161
jest . spyOn ( window , "navigator" , "get" ) . mockImplementation ( ( ) => ( {
145
162
...originalNavigator ,
146
- clipboard : mockClipboardInstance ,
163
+ clipboard : mockClipboard ,
147
164
} ) ) ;
148
165
149
- if ( ! isHttps ) {
150
- // Not the biggest fan of exposing implementation details like this, but
151
- // making any kind of mock for execCommand is really gnarly in general
152
- global . document . execCommand = jest . fn ( ( ) => {
153
- if ( mockClipboardInstance . simulateFailure ) {
154
- return false ;
155
- }
156
-
157
- const dummyInput = document . querySelector ( "input[data-testid=dummy]" ) ;
158
- const inputIsFocused =
159
- dummyInput instanceof HTMLInputElement &&
160
- document . activeElement === dummyInput ;
161
-
162
- let copySuccessful = false ;
163
- if ( inputIsFocused ) {
164
- mockClipboardInstance . setMockText ( dummyInput . value ) ;
165
- copySuccessful = true ;
166
- }
167
-
168
- return copySuccessful ;
169
- } ) ;
170
- }
166
+ jest . spyOn ( console , "error" ) . mockImplementation ( ( errorValue , ...rest ) => {
167
+ const canIgnore =
168
+ errorValue instanceof Error &&
169
+ errorValue . message === COPY_FAILED_MESSAGE ;
170
+
171
+ if ( ! canIgnore ) {
172
+ originalConsoleError ( errorValue , ...rest ) ;
173
+ }
174
+ } ) ;
171
175
} ) ;
172
176
173
177
afterEach ( ( ) => {
178
+ jest . runAllTimers ( ) ;
174
179
jest . useRealTimers ( ) ;
175
- mockClipboardInstance . setMockText ( "" ) ;
176
- mockClipboardInstance . setSimulateFailure ( false ) ;
180
+ jest . resetAllMocks ( ) ;
181
+ global . document . execCommand = originalExecCommand ;
182
+
183
+ // Still have to reset the mock clipboard state because the same mock values
184
+ // are reused for each test case in a given describe.each iteration
185
+ resetMockClipboardState ( ) ;
177
186
} ) ;
178
187
179
- const assertClipboardTextUpdate = async (
180
- result : ReturnType < typeof renderUseClipboard > [ "result" ] ,
188
+ const assertClipboardUpdateLifecycle = async (
189
+ result : RenderResult ,
181
190
textToCheck : string ,
182
191
) : Promise < void > => {
183
192
await act ( ( ) => result . current . copyToClipboard ( ) ) ;
184
193
expect ( result . current . showCopiedSuccess ) . toBe ( true ) ;
185
194
186
- const clipboardText = mockClipboardInstance . getMockText ( ) ;
195
+ // Because of timing trickery, any timeouts for flipping the copy status
196
+ // back to false will usually trigger before any test cases calling this
197
+ // assert function can complete. This will never be an issue in the real
198
+ // world, but it will kick up 'act' warnings in the console, which makes
199
+ // tests more annoying. Getting around that by waiting for all timeouts to
200
+ // wrap up, but note that the value of showCopiedSuccess will become false
201
+ // after runAllTimersAsync finishes
202
+ await act ( ( ) => jest . runAllTimersAsync ( ) ) ;
203
+
204
+ const clipboardText = getClipboardText ( ) ;
187
205
expect ( clipboardText ) . toEqual ( textToCheck ) ;
188
206
} ;
189
207
190
- /**
191
- * Start of test cases
192
- */
193
208
it ( "Copies the current text to the user's clipboard" , async ( ) => {
194
209
const textToCopy = "dogs" ;
195
210
const { result } = renderUseClipboard ( { textToCopy } ) ;
196
- await assertClipboardTextUpdate ( result , textToCopy ) ;
211
+ await assertClipboardUpdateLifecycle ( result , textToCopy ) ;
197
212
} ) ;
198
213
199
214
it ( "Should indicate to components not to show successful copy after a set period of time" , async ( ) => {
200
215
const textToCopy = "cats" ;
201
216
const { result } = renderUseClipboard ( { textToCopy } ) ;
202
- await assertClipboardTextUpdate ( result , textToCopy ) ;
203
-
204
- setTimeout ( ( ) => {
205
- expect ( result . current . showCopiedSuccess ) . toBe ( false ) ;
206
- } , 10_000 ) ;
207
-
208
- await jest . runAllTimersAsync ( ) ;
217
+ await assertClipboardUpdateLifecycle ( result , textToCopy ) ;
218
+ expect ( result . current . showCopiedSuccess ) . toBe ( false ) ;
209
219
} ) ;
210
220
211
221
it ( "Should notify the user of an error using the provided callback" , async ( ) => {
212
222
const textToCopy = "birds" ;
213
223
const onError = jest . fn ( ) ;
214
224
const { result } = renderUseClipboard ( { textToCopy, onError } ) ;
215
225
216
- mockClipboardInstance . setSimulateFailure ( true ) ;
226
+ setSimulateFailure ( true ) ;
217
227
await act ( ( ) => result . current . copyToClipboard ( ) ) ;
218
228
expect ( onError ) . toBeCalled ( ) ;
219
229
} ) ;
220
- }
230
+
231
+ it ( "Should dispatch a new toast message to the global snackbar when errors happen while no error callback is provided to the hook" , async ( ) => {
232
+ const textToCopy = "crow" ;
233
+ const { result } = renderUseClipboard ( { textToCopy } ) ;
234
+
235
+ /**
236
+ * @todo Look into why deferring error-based state updates to the global
237
+ * snackbar still kicks up act warnings, even after wrapping copyToClipboard
238
+ * in act. copyToClipboard should be the main source of the state
239
+ * transitions, but it looks like extra state changes are still getting
240
+ * flushed through the GlobalSnackbar component afterwards
241
+ */
242
+ setSimulateFailure ( true ) ;
243
+ await act ( ( ) => result . current . copyToClipboard ( ) ) ;
244
+
245
+ const errorMessageNode = screen . queryByText ( COPY_FAILED_MESSAGE ) ;
246
+ expect ( errorMessageNode ) . not . toBeNull ( ) ;
247
+ } ) ;
248
+
249
+ it ( "Should expose the error as a value when a copy fails" , async ( ) => {
250
+ // Using empty onError callback to silence any possible act warnings from
251
+ // Snackbar state transitions that you might get if the hook uses the
252
+ // default
253
+ const textToCopy = "hamster" ;
254
+ const { result } = renderUseClipboard ( { textToCopy, onError : jest . fn ( ) } ) ;
255
+
256
+ setSimulateFailure ( true ) ;
257
+ await act ( ( ) => result . current . copyToClipboard ( ) ) ;
258
+
259
+ expect ( result . current . error ) . toBeInstanceOf ( Error ) ;
260
+ } ) ;
261
+ } ) ;
0 commit comments