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;