Skip to content

Commit 63e0685

Browse files
authored
fix: update tests for useClipboard to minimize risks of flakes (coder#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

+175-134
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)