Skip to content

Commit 63e0685

Browse files
authored
fix: update tests for useClipboard to minimize risks of flakes (#13250)
* wip: commit progress on test revamps * fix: update existing tests to new format * chore: add test case for global snackbar * refactor: consolidate files * refactor: make http dependency more explicit * chore: add extra test case for exposed error value * docs: fix typos * fix: make sure clipboard is reset between test runs * docs: add more context to comments * refactor: update mock console.error logic to use jest.spyOn * docs: add more clarifying comments * refactor: split off type alias for clarity
1 parent 114fb31 commit 63e0685

File tree

2 files changed

+178
-136
lines changed

2 files changed

+178
-136
lines changed

site/src/hooks/useClipboard.test.tsx

Lines changed: 175 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,126 @@
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";
213
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
314
import { ThemeProvider } from "contexts/ThemeProvider";
415
import {
516
type UseClipboardInput,
617
type UseClipboardResult,
18+
COPY_FAILED_MESSAGE,
719
useClipboard,
20+
HTTP_FALLBACK_DATA_ID,
821
} from "./useClipboard";
922

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;
1927

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;
6834
}>;
6935

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;
8239

40+
const mockClipboard: Clipboard = {
8341
readText: async () => {
84-
if (shouldFail) {
85-
throw new Error("Clipboard deliberately failed");
86-
}
87-
88-
if (!isSecureContext) {
42+
if (!isSecure) {
8943
throw new Error(
90-
"Trying to read from clipboard outside secure context!",
44+
"Not allowed to access clipboard outside of secure contexts",
9145
);
9246
}
9347

94-
return mockClipboardValue;
48+
if (shouldSimulateFailure) {
49+
throw new Error("Failed to read from clipboard");
50+
}
51+
52+
return mockClipboardText;
9553
},
54+
9655
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+
);
9960
}
10061

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");
10364
}
10465

105-
mockClipboardValue = newText;
106-
},
107-
108-
getMockText: () => mockClipboardValue,
109-
setMockText: (newText) => {
110-
mockClipboardValue = newText;
66+
mockClipboardText = newText;
11167
},
11268

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
11372
addEventListener: jest.fn(),
11473
removeEventListener: jest.fn(),
11574
dispatchEvent: jest.fn(),
11675
read: jest.fn(),
11776
write: jest.fn(),
11877
};
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+
};
119115
}
120116

121-
function renderUseClipboard(inputs: UseClipboardInput) {
122-
return renderHook<UseClipboardResult, UseClipboardInput>(
117+
function renderUseClipboard<TInput extends UseClipboardInput>(inputs: TInput) {
118+
return renderHook<UseClipboardResult, TInput>(
123119
(props) => useClipboard(props),
124120
{
125121
initialProps: inputs,
126122
wrapper: ({ children }) => (
123+
// Need ThemeProvider because GlobalSnackbar uses theme
127124
<ThemeProvider>
128125
{children}
129126
<GlobalSnackbar />
@@ -133,88 +130,132 @@ function renderUseClipboard(inputs: UseClipboardInput) {
133130
);
134131
}
135132

136-
type ScheduleConfig = Readonly<{ isHttps: boolean }>;
133+
type RenderResult = ReturnType<typeof renderUseClipboard>["result"];
137134

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);
141152

142153
beforeEach(() => {
143154
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+
144161
jest.spyOn(window, "navigator", "get").mockImplementation(() => ({
145162
...originalNavigator,
146-
clipboard: mockClipboardInstance,
163+
clipboard: mockClipboard,
147164
}));
148165

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

173177
afterEach(() => {
178+
jest.runAllTimers();
174179
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();
177186
});
178187

179-
const assertClipboardTextUpdate = async (
180-
result: ReturnType<typeof renderUseClipboard>["result"],
188+
const assertClipboardUpdateLifecycle = async (
189+
result: RenderResult,
181190
textToCheck: string,
182191
): Promise<void> => {
183192
await act(() => result.current.copyToClipboard());
184193
expect(result.current.showCopiedSuccess).toBe(true);
185194

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();
187205
expect(clipboardText).toEqual(textToCheck);
188206
};
189207

190-
/**
191-
* Start of test cases
192-
*/
193208
it("Copies the current text to the user's clipboard", async () => {
194209
const textToCopy = "dogs";
195210
const { result } = renderUseClipboard({ textToCopy });
196-
await assertClipboardTextUpdate(result, textToCopy);
211+
await assertClipboardUpdateLifecycle(result, textToCopy);
197212
});
198213

199214
it("Should indicate to components not to show successful copy after a set period of time", async () => {
200215
const textToCopy = "cats";
201216
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);
209219
});
210220

211221
it("Should notify the user of an error using the provided callback", async () => {
212222
const textToCopy = "birds";
213223
const onError = jest.fn();
214224
const { result } = renderUseClipboard({ textToCopy, onError });
215225

216-
mockClipboardInstance.setSimulateFailure(true);
226+
setSimulateFailure(true);
217227
await act(() => result.current.copyToClipboard());
218228
expect(onError).toBeCalled();
219229
});
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

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy