Skip to content

fix: update tests for useClipboard to minimize risks of flakes #13250

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 15, 2024
Merged
Next Next commit
wip: commit progress on test revamps
  • Loading branch information
Parkreiner committed May 12, 2024
commit 8613133643c5718e2cab3280d94760c1c34ce297
172 changes: 172 additions & 0 deletions site/src/hooks/useClipboard.temp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { act, renderHook } from "@testing-library/react";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
import { ThemeProvider } from "contexts/ThemeProvider";
import {
type UseClipboardInput,
type UseClipboardResult,
useClipboard,
} from "./useClipboard";

type SetupMockClipboardResult = Readonly<{
mockClipboard: Clipboard;
getClipboardText: () => string;
setClipboardText: (newText: string) => void;
setSimulateFailure: (shouldFail: boolean) => void;
}>;

function setupMockClipboard(isSecure: boolean): SetupMockClipboardResult {
let mockClipboardText = "";
let shouldSimulateFailure = false;

const mockClipboard: Clipboard = {
readText: async () => {
if (!isSecure) {
throw new Error(
"Not allowed to access clipboard outside of secure contexts",
);
}

if (shouldSimulateFailure) {
throw new Error("Failed to read from clipboard");
}

return mockClipboardText;
},

writeText: async (newText) => {
if (!isSecure) {
throw new Error(
"Not allowed to access clipboard outside of secure contexts",
);
}

if (shouldSimulateFailure) {
throw new Error("Failed to write to clipboard");
}

mockClipboardText = newText;
},

// Don't need these other methods for any of the tests; read and write are
// both synchronous and slower than the promise-based methods, so ideally
// we won't ever need to call them in the hook logic
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
read: jest.fn(),
write: jest.fn(),
};

return {
mockClipboard,
getClipboardText: () => mockClipboardText,
setClipboardText: (newText) => {
mockClipboardText = newText;
},
setSimulateFailure: (newShouldFailValue) => {
shouldSimulateFailure = newShouldFailValue;
},
};
}

function renderUseClipboard<TInput extends UseClipboardInput>(inputs: TInput) {
return renderHook<UseClipboardResult, TInput>(
(props) => useClipboard(props),
{
initialProps: inputs,
wrapper: ({ children }) => (
// Need ThemeProvider because GlobalSnackbar uses theme
<ThemeProvider>
{children}
<GlobalSnackbar />
</ThemeProvider>
),
},
);
}

const secureContextValues: readonly boolean[] = [true, false];
const originalNavigator = window.navigator;
const originalExecCommand = global.document.execCommand;

// Not a big fan of describe.each most of the time, but since we need to test
// the exact same test cases against different inputs, and we want them to run
// as sequentially as possible to minimize flakes, they make sense here
describe.each(secureContextValues)("useClipboard - secure: %j", (context) => {
const {
mockClipboard,
getClipboardText,
setClipboardText,
setSimulateFailure,
} = setupMockClipboard(context);

beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(window, "navigator", "get").mockImplementation(() => ({
...originalNavigator,
clipboard: mockClipboard,
}));

global.document.execCommand = jest.fn(() => {
const dummyInput = document.querySelector("input[data-testid=dummy]");
const inputIsFocused =
dummyInput instanceof HTMLInputElement &&
document.activeElement === dummyInput;

let copySuccessful = false;
if (inputIsFocused) {
setClipboardText(dummyInput.value);
copySuccessful = true;
}

return copySuccessful;
});
});

afterEach(() => {
jest.runAllTimers();
jest.useRealTimers();
jest.resetAllMocks();
global.document.execCommand = originalExecCommand;
});

const assertClipboardTextUpdate = async (
result: ReturnType<typeof renderUseClipboard>["result"],
textToCheck: string,
): Promise<void> => {
await act(() => result.current.copyToClipboard());
expect(result.current.showCopiedSuccess).toBe(true);

const clipboardText = getClipboardText();
expect(clipboardText).toEqual(textToCheck);
};

it("Copies the current text to the user's clipboard", async () => {
const textToCopy = "dogs";
const { result } = renderUseClipboard({ textToCopy });
await assertClipboardTextUpdate(result, textToCopy);
});

it("Should indicate to components not to show successful copy after a set period of time", async () => {
const textToCopy = "cats";
const { result } = renderUseClipboard({ textToCopy });
await assertClipboardTextUpdate(result, textToCopy);

await jest.runAllTimersAsync();
expect(result.current.showCopiedSuccess).toBe(false);
});

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

setSimulateFailure(true);
await act(() => result.current.copyToClipboard());
expect(onError).toBeCalled();
});

it.skip("Should dispatch a new toast message to the global snackbar if no callback is provided", async () => {
expect.hasAssertions();
});
});