Files
multica/packages/views/editor/utils/clipboard.test.ts
Bohan Jiang d6540a1869 fix(clipboard): support copy over http:// via execCommand fallback (#3810)
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>
2026-06-05 14:55:23 +08:00

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);
});
});