9
9
* immediately pollutes the tests with false negatives. Even if something should
10
10
* fail, it won't.
11
11
*/
12
- import { act , renderHook , screen } from "@testing-library/react" ;
12
+
13
+ import { renderHook , screen } from "@testing-library/react" ;
13
14
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar" ;
14
15
import { ThemeOverride } from "contexts/ThemeProvider" ;
16
+ import { act } from "react" ;
15
17
import themes , { DEFAULT_THEME } from "theme" ;
16
18
import {
17
19
COPY_FAILED_MESSAGE ,
@@ -115,8 +117,8 @@ function setupMockClipboard(isSecure: boolean): SetupMockClipboardResult {
115
117
} ;
116
118
}
117
119
118
- function renderUseClipboard < TInput extends UseClipboardInput > ( inputs : TInput ) {
119
- return renderHook < UseClipboardResult , TInput > (
120
+ function renderUseClipboard ( inputs ?: UseClipboardInput ) {
121
+ return renderHook < UseClipboardResult , UseClipboardInput > (
120
122
( props ) => useClipboard ( props ) ,
121
123
{
122
124
initialProps :inputs ,
@@ -188,9 +190,9 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
188
190
189
191
const assertClipboardUpdateLifecycle = async (
190
192
result :RenderResult ,
191
- textToCheck :string ,
193
+ textToCopy :string ,
192
194
) :Promise < void > => {
193
- await act ( ( ) => result . current . copyToClipboard ( ) ) ;
195
+ await act ( ( ) => result . current . copyToClipboard ( textToCopy ) ) ;
194
196
expect ( result . current . showCopiedSuccess ) . toBe ( true ) ;
195
197
196
198
// Because of timing trickery, any timeouts for flipping the copy status
@@ -203,35 +205,35 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
203
205
await act ( ( ) => jest . runAllTimersAsync ( ) ) ;
204
206
205
207
const clipboardText = getClipboardText ( ) ;
206
- expect ( clipboardText ) . toEqual ( textToCheck ) ;
208
+ expect ( clipboardText ) . toEqual ( textToCopy ) ;
207
209
} ;
208
210
209
211
it ( "Copies the current text to the user's clipboard" , async ( ) => {
210
212
const textToCopy = "dogs" ;
211
- const { result} = renderUseClipboard ( { textToCopy } ) ;
213
+ const { result} = renderUseClipboard ( ) ;
212
214
await assertClipboardUpdateLifecycle ( result , textToCopy ) ;
213
215
} ) ;
214
216
215
217
it ( "Should indicate to components not to show successful copy after a set period of time" , async ( ) => {
216
218
const textToCopy = "cats" ;
217
- const { result} = renderUseClipboard ( { textToCopy } ) ;
219
+ const { result} = renderUseClipboard ( ) ;
218
220
await assertClipboardUpdateLifecycle ( result , textToCopy ) ;
219
221
expect ( result . current . showCopiedSuccess ) . toBe ( false ) ;
220
222
} ) ;
221
223
222
224
it ( "Should notify the user of an error using the provided callback" , async ( ) => {
223
225
const textToCopy = "birds" ;
224
226
const onError = jest . fn ( ) ;
225
- const { result} = renderUseClipboard ( { textToCopy , onError} ) ;
227
+ const { result} = renderUseClipboard ( { onError} ) ;
226
228
227
229
setSimulateFailure ( true ) ;
228
- await act ( ( ) => result . current . copyToClipboard ( ) ) ;
230
+ await act ( ( ) => result . current . copyToClipboard ( textToCopy ) ) ;
229
231
expect ( onError ) . toBeCalled ( ) ;
230
232
} ) ;
231
233
232
234
it ( "Should dispatch a new toast message to the global snackbar when errors happen while no error callback is provided to the hook" , async ( ) => {
233
235
const textToCopy = "crow" ;
234
- const { result} = renderUseClipboard ( { textToCopy } ) ;
236
+ const { result} = renderUseClipboard ( ) ;
235
237
236
238
/**
237
239
*@todo Look into why deferring error-based state updates to the global
@@ -241,7 +243,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
241
243
* flushed through the GlobalSnackbar component afterwards
242
244
*/
243
245
setSimulateFailure ( true ) ;
244
- await act ( ( ) => result . current . copyToClipboard ( ) ) ;
246
+ await act ( ( ) => result . current . copyToClipboard ( textToCopy ) ) ;
245
247
246
248
const errorMessageNode = screen . queryByText ( COPY_FAILED_MESSAGE ) ;
247
249
expect ( errorMessageNode ) . not . toBeNull ( ) ;
@@ -252,11 +254,91 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
252
254
// Snackbar state transitions that you might get if the hook uses the
253
255
// default
254
256
const textToCopy = "hamster" ;
255
- const { result} = renderUseClipboard ( { textToCopy, onError :jest . fn ( ) } ) ;
257
+ const { result} = renderUseClipboard ( { onError :jest . fn ( ) } ) ;
258
+
259
+ setSimulateFailure ( true ) ;
260
+ await act ( ( ) => result . current . copyToClipboard ( textToCopy ) ) ;
261
+
262
+ expect ( result . current . error ) . toBeInstanceOf ( Error ) ;
263
+ } ) ;
256
264
265
+ it ( "Clears out existing errors if a new copy operation succeeds" , async ( ) => {
266
+ const text = "dummy-text" ;
267
+ const { result} = renderUseClipboard ( ) ;
257
268
setSimulateFailure ( true ) ;
258
- await act ( ( ) => result . current . copyToClipboard ( ) ) ;
259
269
270
+ await act ( ( ) => result . current . copyToClipboard ( text ) ) ;
260
271
expect ( result . current . error ) . toBeInstanceOf ( Error ) ;
272
+
273
+ setSimulateFailure ( false ) ;
274
+ await assertClipboardUpdateLifecycle ( result , text ) ;
275
+ expect ( result . current . error ) . toBeUndefined ( ) ;
276
+ } ) ;
277
+
278
+ // This test case is really important to ensure that it's easy to plop this
279
+ // inside of useEffect calls without having to think about dependencies too
280
+ // much
281
+ it ( "Ensures that the copyToClipboard function always maintains a stable reference across all re-renders" , async ( ) => {
282
+ const initialOnError = jest . fn ( ) ;
283
+ const { result, rerender} = renderUseClipboard ( {
284
+ onError :initialOnError ,
285
+ clearErrorOnSuccess :true ,
286
+ } ) ;
287
+ const initialCopy = result . current . copyToClipboard ;
288
+
289
+ // Re-render arbitrarily with no clipboard state transitions to make
290
+ // sure that a parent re-rendering doesn't break anything
291
+ rerender ( { onError :initialOnError } ) ;
292
+ expect ( result . current . copyToClipboard ) . toBe ( initialCopy ) ;
293
+
294
+ // Re-render with new onError prop and then swap back to simplify
295
+ // testing
296
+ rerender ( { onError :jest . fn ( ) } ) ;
297
+ expect ( result . current . copyToClipboard ) . toBe ( initialCopy ) ;
298
+ rerender ( { onError :initialOnError } ) ;
299
+
300
+ // Re-render with a new clear value then swap back to simplify testing
301
+ rerender ( { onError :initialOnError , clearErrorOnSuccess :false } ) ;
302
+ expect ( result . current . copyToClipboard ) . toBe ( initialCopy ) ;
303
+ rerender ( { onError :initialOnError , clearErrorOnSuccess :true } ) ;
304
+
305
+ // Trigger a failed clipboard interaction
306
+ setSimulateFailure ( true ) ;
307
+ await act ( ( ) => result . current . copyToClipboard ( "dummy-text-2" ) ) ;
308
+ expect ( result . current . copyToClipboard ) . toBe ( initialCopy ) ;
309
+
310
+ /**
311
+ * Trigger a successful clipboard interaction
312
+ *
313
+ *@todo For some reason, using the assertClipboardUpdateLifecycle
314
+ * helper triggers Jest errors with it thinking that values are being
315
+ * accessed after teardown, even though the problem doesn't exist for
316
+ * any other test case.
317
+ *
318
+ * It's not a huge deal, because we only need to inspect React after the
319
+ * interaction, instead of the full DOM, but for correctness, it would
320
+ * be nice if we could get this issue figured out.
321
+ */
322
+ setSimulateFailure ( false ) ;
323
+ await act ( ( ) => result . current . copyToClipboard ( "dummy-text-2" ) ) ;
324
+ expect ( result . current . copyToClipboard ) . toBe ( initialCopy ) ;
325
+ } ) ;
326
+
327
+ it ( "Always uses the most up-to-date onError prop" , async ( ) => {
328
+ const initialOnError = jest . fn ( ) ;
329
+ const { result, rerender} = renderUseClipboard ( {
330
+ onError :initialOnError ,
331
+ } ) ;
332
+ setSimulateFailure ( true ) ;
333
+
334
+ const secondOnError = jest . fn ( ) ;
335
+ rerender ( { onError :secondOnError } ) ;
336
+ await act ( ( ) => result . current . copyToClipboard ( "dummy-text" ) ) ;
337
+
338
+ expect ( initialOnError ) . not . toHaveBeenCalled ( ) ;
339
+ expect ( secondOnError ) . toHaveBeenCalledTimes ( 1 ) ;
340
+ expect ( secondOnError ) . toHaveBeenCalledWith (
341
+ "Failed to copy text to clipboard" ,
342
+ ) ;
261
343
} ) ;
262
344
} ) ;