mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
navigator.clipboard is only exposed in a secure context (https or localhost). On self-hosted instances served over plain http:// it is undefined, so every copy / "copy all" / export button silently failed and left the clipboard empty (GitHub #3781). Add a shared copyText(text): Promise<boolean> helper in @multica/ui/lib/clipboard that prefers the async Clipboard API and falls back to a hidden <textarea> + document.execCommand('copy') for non-secure contexts. Migrate all direct navigator.clipboard.writeText call sites (code blocks, agent transcript copy-all, token / webhook / issue-link copy, etc.) to it, gating success side-effects on the returned boolean, and remove the now-redundant copyMarkdown wrapper. Secure-context users keep the native path unchanged. MUL-3068 Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
74 lines
2.1 KiB
TypeScript
74 lines
2.1 KiB
TypeScript
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
import { copyText } from "@multica/ui/lib/clipboard";
|
|
|
|
// jsdom implements neither navigator.clipboard nor document.execCommand, so we
|
|
// define them per test to simulate secure (https/localhost) vs insecure (plain
|
|
// http://) contexts.
|
|
function setClipboard(value: unknown): void {
|
|
Object.defineProperty(navigator, "clipboard", {
|
|
value,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
}
|
|
|
|
function setExecCommand(result: boolean): ReturnType<typeof vi.fn> {
|
|
const mock = vi.fn().mockReturnValue(result);
|
|
Object.defineProperty(document, "execCommand", {
|
|
value: mock,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
return mock;
|
|
}
|
|
|
|
afterEach(() => {
|
|
setClipboard(undefined);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("copyText", () => {
|
|
it("uses the async Clipboard API in a secure context and returns true", async () => {
|
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
setClipboard({ writeText });
|
|
|
|
const ok = await copyText("hello");
|
|
|
|
expect(ok).toBe(true);
|
|
expect(writeText).toHaveBeenCalledWith("hello");
|
|
});
|
|
|
|
it("falls back to execCommand when navigator.clipboard is unavailable (http://)", async () => {
|
|
setClipboard(undefined);
|
|
const execCommand = setExecCommand(true);
|
|
|
|
const ok = await copyText("from-http");
|
|
|
|
expect(ok).toBe(true);
|
|
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
// The temporary textarea must be cleaned up afterwards.
|
|
expect(document.querySelector("textarea")).toBeNull();
|
|
});
|
|
|
|
it("falls back to execCommand when writeText rejects", async () => {
|
|
const writeText = vi.fn().mockRejectedValue(new Error("blocked"));
|
|
setClipboard({ writeText });
|
|
const execCommand = setExecCommand(true);
|
|
|
|
const ok = await copyText("retry");
|
|
|
|
expect(ok).toBe(true);
|
|
expect(writeText).toHaveBeenCalledWith("retry");
|
|
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
});
|
|
|
|
it("returns false when the fallback execCommand fails", async () => {
|
|
setClipboard(undefined);
|
|
setExecCommand(false);
|
|
|
|
const ok = await copyText("nope");
|
|
|
|
expect(ok).toBe(false);
|
|
});
|
|
});
|