diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx index c180832d5..44eaf12be 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.tsx @@ -2,9 +2,8 @@ import { act, createRef } from "react"; import { render, fireEvent } from "@testing-library/react"; -import copy from "copy-to-clipboard"; - import { MockAlertGroup, MockAlert } from "__fixtures__/Alerts"; +import { mockClipboard } from "__fixtures__/Clipboard"; import type { APIAlertGroupT, APIAlertT, @@ -22,6 +21,7 @@ let group: APIAlertGroupT; let MockAfterClick: () => void; let MockSetIsMenuOpen: () => void; +let writeText: jest.Mock; const generateUpstreams = (): APIAlertsResponseUpstreamsT => ({ counters: { total: 1, healthy: 1, failed: 0 }, @@ -68,6 +68,7 @@ const generateUpstreams = (): APIAlertsResponseUpstreamsT => ({ beforeEach(() => { jest.useFakeTimers(); + writeText = mockClipboard(); alertStore = new AlertStore([]); silenceFormStore = new SilenceFormStore(); @@ -286,7 +287,10 @@ describe("", () => { const { container } = renderMenuContent(group); const buttons = container.querySelectorAll(".dropdown-item"); fireEvent.click(buttons[1]); - expect(copy).toHaveBeenCalledWith( + // flush async clipboard write from copy-to-clipboard + await act(() => jest.advanceTimersByTimeAsync(0)); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith( JSON.stringify(alertToJSON(group, alert)), ); }); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx index f8539943f..bcba37b0e 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.tsx @@ -127,7 +127,7 @@ const MenuContent = observer(
{ - copy(JSON.stringify(alertToJSON(group, alert))); + void copy(JSON.stringify(alertToJSON(group, alert))); afterClick(); }} > diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.test.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.test.tsx index 44e654eb5..8f0d7d4e2 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.test.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.test.tsx @@ -2,9 +2,8 @@ import { act } from "react"; import { render, fireEvent } from "@testing-library/react"; -import copy from "copy-to-clipboard"; - import { MockAlertGroup } from "__fixtures__/Alerts"; +import { mockClipboard } from "__fixtures__/Clipboard"; import type { APIAlertGroupT, APIAlertsResponseUpstreamsT, @@ -18,6 +17,7 @@ let silenceFormStore: SilenceFormStore; let MockAfterClick: () => void; let MockSetIsMenuOpen: () => void; +let writeText: jest.Mock; const generateUpstreams = (): APIAlertsResponseUpstreamsT => ({ counters: { total: 3, healthy: 3, failed: 0 }, @@ -68,6 +68,7 @@ beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); + writeText = mockClipboard(); MockAfterClick = jest.fn(); MockSetIsMenuOpen = jest.fn(); @@ -188,7 +189,7 @@ const renderMenuContent = (group: APIAlertGroupT) => { }; describe("", () => { - it("clicking on 'Copy' icon copies the link to clickboard", () => { + it("clicking on 'Copy' icon copies the link to clickboard", async () => { const group = MockAlertGroup( [{ name: "alertname", value: "Fake Alert" }], [], @@ -199,7 +200,9 @@ describe("", () => { const { container } = renderMenuContent(group); const buttons = container.querySelectorAll(".dropdown-item"); fireEvent.click(buttons[0]); - expect(copy).toHaveBeenCalledTimes(1); + // flush async clipboard write from copy-to-clipboard + await act(() => jest.advanceTimersByTimeAsync(0)); + expect(writeText).toHaveBeenCalledTimes(1); }); it("clicking on 'Silence' icon opens the silence form modal", () => { diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx index 5c7e49187..034c032dc 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/GroupHeader/GroupMenu.tsx @@ -118,7 +118,7 @@ const MenuContent: FC<{
{ - copy(groupLink); + void copy(groupLink); afterClick(); }} > diff --git a/ui/src/Components/Labels/FilteringLabel/index.test.tsx b/ui/src/Components/Labels/FilteringLabel/index.test.tsx index 19bd121ac..4b5fdd9db 100644 --- a/ui/src/Components/Labels/FilteringLabel/index.test.tsx +++ b/ui/src/Components/Labels/FilteringLabel/index.test.tsx @@ -1,15 +1,18 @@ +import { act } from "react"; + import { render, screen, fireEvent } from "@testing-library/react"; -import copy from "copy-to-clipboard"; - import { AlertStore, NewUnappliedFilter } from "Stores/AlertStore"; +import { mockClipboard } from "__fixtures__/Clipboard"; import FilteringLabel from "."; let alertStore: AlertStore; +let writeText: jest.Mock; beforeEach(() => { alertStore = new AlertStore([]); + writeText = mockClipboard(); }); const renderFilteringLabel = (name: string, value: string) => { @@ -60,9 +63,12 @@ describe("", () => { ); }); - it("calling onClick() while holding Shift key copies label value to clipboard", () => { + it("calling onClick() while holding Shift key copies label value to clipboard", async () => { renderAndClick("foo", "bar", { shiftKey: true }); - expect(copy).toHaveBeenCalledWith("bar"); + // flush async clipboard write from copy-to-clipboard + await act(async () => {}); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith("bar"); }); it("label with dark background color should have 'components-label-dark' class", () => { diff --git a/ui/src/Components/Labels/FilteringLabel/index.tsx b/ui/src/Components/Labels/FilteringLabel/index.tsx index c937695c1..22646c5bd 100644 --- a/ui/src/Components/Labels/FilteringLabel/index.tsx +++ b/ui/src/Components/Labels/FilteringLabel/index.tsx @@ -23,7 +23,7 @@ const FilteringLabel: FC<{ event.preventDefault(); if (event.shiftKey) { - copy(value); + void copy(value); return; } diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.test.tsx b/ui/src/Components/ManagedSilence/SilenceDetails.test.tsx index 56b61a6cd..c9695f900 100644 --- a/ui/src/Components/ManagedSilence/SilenceDetails.test.tsx +++ b/ui/src/Components/ManagedSilence/SilenceDetails.test.tsx @@ -1,8 +1,9 @@ +import { act } from "react"; + import { render, fireEvent } from "@testing-library/react"; -import copy from "copy-to-clipboard"; - import { MockSilence } from "__fixtures__/Alerts"; +import { mockClipboard } from "__fixtures__/Clipboard"; import type { APIAlertsResponseUpstreamsT, APISilenceT } from "Models/APITypes"; import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; @@ -12,6 +13,7 @@ let alertStore: AlertStore; let silenceFormStore: SilenceFormStore; let cluster: string; let silence: APISilenceT; +let writeText: jest.Mock; const MockEditSilence = jest.fn(); @@ -43,6 +45,7 @@ beforeEach(() => { alertStore.data.setUpstreams(generateUpstreams()); jest.restoreAllMocks(); + writeText = mockClipboard(); jest.useFakeTimers(); }); @@ -86,12 +89,14 @@ describe("", () => { ); }); - it("clicking on the copy button copies silence ID to the clipboard", () => { + it("clicking on the copy button copies silence ID to the clipboard", async () => { const { container } = renderSilenceDetails(); const button = container.querySelector("span.badge.bg-secondary"); fireEvent.click(button!); - expect(copy).toHaveBeenCalledTimes(1); - expect(copy).toHaveBeenCalledWith(silence.id); + // flush async clipboard write from copy-to-clipboard + await act(() => jest.advanceTimersByTimeAsync(0)); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(silence.id); }); it("Edit silence button is disabled when all alertmanager instances are read-only", () => { diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.tsx b/ui/src/Components/ManagedSilence/SilenceDetails.tsx index 4336acdee..cc621fe01 100644 --- a/ui/src/Components/ManagedSilence/SilenceDetails.tsx +++ b/ui/src/Components/ManagedSilence/SilenceDetails.tsx @@ -39,7 +39,7 @@ const SilenceIDCopyButton: FC<{ ref={ref} className="badge bg-secondary px-1 me-1 components-label cursor-pointer" onClick={() => { - copy(id); + void copy(id); setClickCount(clickCount + 1); }} > diff --git a/ui/src/Components/SilenceModal/SilenceForm.test.tsx b/ui/src/Components/SilenceModal/SilenceForm.test.tsx index 2eec03c60..3373dff87 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.test.tsx +++ b/ui/src/Components/SilenceModal/SilenceForm.test.tsx @@ -1,7 +1,8 @@ +import { act } from "react"; + import { render, fireEvent } from "@testing-library/react"; -import copy from "copy-to-clipboard"; - +import { mockClipboard } from "__fixtures__/Clipboard"; import { MockThemeContext } from "__fixtures__/Theme"; import { AlertStore, NewUnappliedFilter } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; @@ -14,6 +15,7 @@ import type { APIAlertsResponseUpstreamsT } from "Models/APITypes"; let alertStore: AlertStore; let settingsStore: Settings; let silenceFormStore: SilenceFormStore; +let writeText: jest.Mock; const generateUpstreams = ( version = "0.24.0", @@ -47,6 +49,7 @@ beforeEach(() => { settingsStore = new Settings(null); silenceFormStore = new SilenceFormStore(); alertStore.data.setUpstreams(generateUpstreams()); + writeText = mockClipboard(); }); const renderSilenceForm = () => { @@ -510,7 +513,7 @@ describe(" preview", () => { expect(container.querySelector(".mt-4")).toBeNull(); }); - it("clicking on the copy button copies form link to the clipboard", () => { + it("clicking on the copy button copies form link to the clipboard", async () => { const matcher = NewEmptyMatcher(); matcher.name = "job"; matcher.values = [ @@ -531,7 +534,9 @@ describe(" preview", () => { ); expect(button?.innerHTML).toMatch(/fa-copy/); fireEvent.click(button!); - expect(copy).toHaveBeenCalledTimes(1); + // flush async clipboard write from copy-to-clipboard + await act(async () => {}); + expect(writeText).toHaveBeenCalledTimes(1); }); it("silence form share link doesn't change on new input", () => { diff --git a/ui/src/Components/SilenceModal/SilenceForm.tsx b/ui/src/Components/SilenceModal/SilenceForm.tsx index d482dd187..f361ba7b9 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.tsx +++ b/ui/src/Components/SilenceModal/SilenceForm.tsx @@ -92,7 +92,7 @@ const ShareButton: FC<{ ref={ref} className="input-group-text text-muted cursor-pointer" onClick={() => { - copy(`${baseURL}?m=${silenceFormStore.data.toBase64}`); + void copy(`${baseURL}?m=${silenceFormStore.data.toBase64}`); setClickCount(clickCount + 1); }} > diff --git a/ui/src/__fixtures__/Clipboard.ts b/ui/src/__fixtures__/Clipboard.ts new file mode 100644 index 000000000..4cdb1e831 --- /dev/null +++ b/ui/src/__fixtures__/Clipboard.ts @@ -0,0 +1,21 @@ +// jsdom lacks navigator.clipboard and window.isSecureContext, +// mock both so copy-to-clipboard uses the async clipboard API path. +const mockClipboard = (): jest.Mock => { + const writeText = jest.fn(() => Promise.resolve()); + + Object.defineProperty(window, "isSecureContext", { + value: true, + writable: true, + configurable: true, + }); + + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + writable: true, + configurable: true, + }); + + return writeText; +}; + +export { mockClipboard }; diff --git a/ui/src/__mocks__/copy-to-clipboard.ts b/ui/src/__mocks__/copy-to-clipboard.ts deleted file mode 100644 index d30cebfdf..000000000 --- a/ui/src/__mocks__/copy-to-clipboard.ts +++ /dev/null @@ -1,6 +0,0 @@ -// mock copy-to-clipboard since it throws errors in tests -// and we don't really need to copy anything, only ensure we're calling it - -const copy = jest.fn(); - -export default copy;