diff --git a/.changeset/fn-7885-clipboard-migration.md b/.changeset/fn-7885-clipboard-migration.md new file mode 100644 index 0000000000..90ec84087c --- /dev/null +++ b/.changeset/fn-7885-clipboard-migration.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix copy actions crashing or mis-reporting on non-secure origins (mobile/HTTP). +category: fix +dev: Migrated remaining dashboard copy handlers (agent id, secrets, git manager, CLI binary, PR conflicts, stash ref, login instructions, agent-error modal) and the reports plugin share-blocks panel from direct navigator.clipboard.writeText to the shared copyTextToClipboard helper (secure-context guard + execCommand fallback, boolean result handling). Added ./app/utils/copyToClipboard subpath export from @fusion/dashboard. diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index 06c39d2c34..bcfa62c2ca 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -36,6 +36,7 @@ import { AgentTaskBadge } from "./AgentTaskBadge"; import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal"; import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor"; import { useFavorites } from "../hooks/useFavorites"; +import { copyTextToClipboard } from "../utils/copyToClipboard"; /** * Simple className utility - joins class names conditionally @@ -642,10 +643,19 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild return getAgentHealthStatus(agent); }; - const copyAgentId = () => { + /* + FNXC:Clipboard 2026-07-12-00:00: + Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback. + */ + const copyAgentId = async () => { if (agent) { - navigator.clipboard.writeText(agent.id); - addToast(t("agents.idCopied", "Agent ID copied to clipboard"), "success"); + const copied = await copyTextToClipboard(agent.id); + addToast( + copied + ? t("agents.idCopied", "Agent ID copied to clipboard") + : t("agents.idCopyFailed", "Failed to copy agent ID"), + copied ? "success" : "error" + ); } }; diff --git a/packages/dashboard/app/components/AgentErrorDetailsModal.tsx b/packages/dashboard/app/components/AgentErrorDetailsModal.tsx index 20a7724316..9a914d56a1 100644 --- a/packages/dashboard/app/components/AgentErrorDetailsModal.tsx +++ b/packages/dashboard/app/components/AgentErrorDetailsModal.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; +import { copyTextToClipboard } from "../utils/copyToClipboard"; const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new"; @@ -78,7 +79,9 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext type="button" className="btn btn-sm" onClick={() => { - void navigator.clipboard.writeText(errorText).then(() => { + /* FNXC:Clipboard 2026-07-12-00:00: Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback. */ + void copyTextToClipboard(errorText).then((copiedToClipboard) => { + if (!copiedToClipboard) return; setCopied(true); setTimeout(() => setCopied(false), 1500); }); diff --git a/packages/dashboard/app/components/CliBinaryPanel.tsx b/packages/dashboard/app/components/CliBinaryPanel.tsx index a849eb6903..d9f586235d 100644 --- a/packages/dashboard/app/components/CliBinaryPanel.tsx +++ b/packages/dashboard/app/components/CliBinaryPanel.tsx @@ -6,6 +6,7 @@ import { type FnBinaryInstallResult, type FnBinaryStatus, } from "../api/legacy"; +import { copyTextToClipboard } from "../utils/copyToClipboard"; import "./CliBinaryPanel.css"; interface Props { @@ -87,14 +88,15 @@ export function CliBinaryPanel({ defer = false }: Props) { } }, []); + /* + FNXC:Clipboard 2026-07-12-00:00: + Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback. + */ const copy = useCallback(async (label: string, value: string) => { - try { - await navigator.clipboard.writeText(value); - setCopied(label); - setTimeout(() => setCopied((c) => (c === label ? null : c)), 1500); - } catch { - // Clipboard API unavailable — leave button silent rather than throwing. - } + const copiedToClipboard = await copyTextToClipboard(value); + if (!copiedToClipboard) return; + setCopied(label); + setTimeout(() => setCopied((c) => (c === label ? null : c)), 1500); }, []); const stateMeta = status ? getStateLabel(status.state) : null; diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx index 4ecfe502ef..eaa92f1794 100644 --- a/packages/dashboard/app/components/GitManagerModal.tsx +++ b/packages/dashboard/app/components/GitManagerModal.tsx @@ -12,6 +12,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation"; import { useViewportMode } from "../hooks/useViewportMode"; +import { copyTextToClipboard } from "../utils/copyToClipboard"; import type { GitStatus, GitCommit, @@ -149,14 +150,23 @@ function FileStatusBadge({ status }: { status: GitFileChange["status"] }) { return {label}; } -/** Copy text to clipboard with toast feedback */ +/** + * Copy text to clipboard with toast feedback. + * + * FNXC:Clipboard 2026-07-12-00:00: + * Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback. + */ function useCopyToClipboard(addToast: (msg: string, type?: ToastType) => void) { const { t } = useTranslation("app"); return useCallback( async (text: string, label?: string) => { try { - await navigator.clipboard.writeText(text); - addToast(label ? t("git.copiedLabel", "Copied {{label}}", { label }) : t("git.copiedToClipboard", "Copied to clipboard"), "success"); + const copied = await copyTextToClipboard(text); + if (copied) { + addToast(label ? t("git.copiedLabel", "Copied {{label}}", { label }) : t("git.copiedToClipboard", "Copied to clipboard"), "success"); + } else { + addToast(t("git.failedToCopy", "Failed to copy"), "error"); + } } catch { addToast(t("git.failedToCopy", "Failed to copy"), "error"); } diff --git a/packages/dashboard/app/components/LoginInstructions.tsx b/packages/dashboard/app/components/LoginInstructions.tsx index 4b877b0098..b2095dbbfa 100644 --- a/packages/dashboard/app/components/LoginInstructions.tsx +++ b/packages/dashboard/app/components/LoginInstructions.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Copy, Check } from "lucide-react"; +import { copyTextToClipboard } from "../utils/copyToClipboard"; import "./LoginInstructions.css"; interface LoginInstructionsProps { @@ -49,14 +50,14 @@ export function LoginInstructions({ instructions, "data-testid": testId }: Login setTimeout(() => setCopied(false), 2000); }, []); + /* + FNXC:Clipboard 2026-07-12-00:00: + Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback. + */ const handleCopy = useCallback(async () => { const textToCopy = deviceCode ?? instructions; - try { - await navigator.clipboard.writeText(textToCopy); - markCopied(); - } catch { - // Ignore copy failures - } + const copiedToClipboard = await copyTextToClipboard(textToCopy); + if (copiedToClipboard) markCopied(); }, [deviceCode, instructions, markCopied]); useEffect(() => { @@ -68,12 +69,8 @@ export function LoginInstructions({ instructions, "data-testid": testId }: Login } const autoCopy = async () => { - try { - await navigator.clipboard.writeText(deviceCode); - markCopied(); - } catch { - // Ignore copy failures - } + const copiedToClipboard = await copyTextToClipboard(deviceCode); + if (copiedToClipboard) markCopied(); }; void autoCopy(); diff --git a/packages/dashboard/app/components/PrPanel.tsx b/packages/dashboard/app/components/PrPanel.tsx index fa56778cbf..f1841dad73 100644 --- a/packages/dashboard/app/components/PrPanel.tsx +++ b/packages/dashboard/app/components/PrPanel.tsx @@ -7,6 +7,7 @@ import { usePrChecksStream } from "../hooks/usePrChecksStream"; import { PrChecksList } from "./PrChecksList"; import type { ToastType } from "../hooks/useToast"; import { linkifyFilePaths } from "../utils/filePathLinkify"; +import { copyTextToClipboard } from "../utils/copyToClipboard"; import "./PrPanel.css"; interface PrPanelProps { @@ -289,7 +290,13 @@ function PrCard({ {conflictDiagnostics && (prInfo.mergeable === "conflicting" || hasConflictBlockingReason) ? (
- {conflictsExpanded ? <>
{conflictDiagnostics.suggestedCommands.join("\n")}
{t("git.capturedAt", "Captured:")} {new Date(conflictDiagnostics.capturedAt).toLocaleString()}
: null} + {conflictsExpanded ? <>
{conflictDiagnostics.suggestedCommands.join("\n")}
{t("git.capturedAt", "Captured:")} {new Date(conflictDiagnostics.capturedAt).toLocaleString()}
: null}
) : null} diff --git a/packages/dashboard/app/components/SecretsView.tsx b/packages/dashboard/app/components/SecretsView.tsx index ec501037dd..42fdcd955a 100644 --- a/packages/dashboard/app/components/SecretsView.tsx +++ b/packages/dashboard/app/components/SecretsView.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronDown, ChevronRight, Copy, Eye, EyeOff, Lock, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { ViewHeader } from "./ViewHeader"; +import { copyTextToClipboard } from "../utils/copyToClipboard"; type ToastKind = "info" | "success" | "error"; type SecretScope = "project" | "global"; @@ -254,10 +255,18 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => { revealTimersRef.current.set(secret.id, timer); }; + /* + FNXC:Clipboard 2026-07-12-00:00: + Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback. + */ const copySecret = async (secret: SecretRecord) => { const revealed = revealedValues[secret.id]; if (!revealed) return; - await navigator.clipboard.writeText(revealed); + const copied = await copyTextToClipboard(revealed); + if (!copied) { + addToast?.(t("secrets.copyFailed", "Failed to copy secret"), "error"); + return; + } setCopiedId(secret.id); addToast?.(t("secrets.copied", "Copied"), "success"); const timer = setTimeout(() => { diff --git a/packages/dashboard/app/components/StashConflictModal.tsx b/packages/dashboard/app/components/StashConflictModal.tsx index 6e1f7dafc4..b39e59f721 100644 --- a/packages/dashboard/app/components/StashConflictModal.tsx +++ b/packages/dashboard/app/components/StashConflictModal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Copy } from "lucide-react"; import { ApiRequestError, api } from "../api"; import { useFileBrowser } from "../context/FileBrowserContext"; +import { copyTextToClipboard } from "../utils/copyToClipboard"; import "./StashConflictModal.css"; interface ResolveResponse { @@ -222,13 +223,13 @@ export default function StashConflictModal({ } }; + /* + FNXC:Clipboard 2026-07-12-00:00: + Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback. + */ const copyRef = async () => { - try { - await navigator.clipboard.writeText(stashSha); - setCopyState("copied"); - } catch { - setCopyState("failed"); - } + const copied = await copyTextToClipboard(stashSha); + setCopyState(copied ? "copied" : "failed"); }; return ( diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx new file mode 100644 index 0000000000..20f480eb48 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + mockFetchAgent, + setupAgentDetailMocks, +} from "./AgentDetailView.test-helpers"; +import { AgentDetailView } from "../AgentDetailView"; + +const originalClipboard = navigator.clipboard; +const originalExecCommand = document.execCommand; + +function mockClipboardFallback(result: boolean) { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const execCommand = vi.fn().mockReturnValue(result); + Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand }); + return execCommand; +} + +describe("AgentDetailView clipboard copy", () => { + beforeEach(() => { + setupAgentDetailMocks(); + }); + + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard }); + Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand }); + }); + + it("copies the agent id through execCommand when Clipboard API is unavailable", async () => { + const execCommand = mockClipboardFallback(true); + const addToast = vi.fn(); + + render(); + await waitFor(() => expect(mockFetchAgent).toHaveBeenCalled()); + + await userEvent.click(screen.getByTitle("Copy Agent ID")); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(addToast).toHaveBeenCalledWith("Agent ID copied to clipboard", "success"); + }); + + it("shows the failure toast instead of false success when both clipboard paths fail", async () => { + const execCommand = mockClipboardFallback(false); + const addToast = vi.fn(); + + render(); + await waitFor(() => expect(mockFetchAgent).toHaveBeenCalled()); + + await userEvent.click(screen.getByTitle("Copy Agent ID")); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(addToast).toHaveBeenCalledWith("Failed to copy agent ID", "error"); + expect(addToast).not.toHaveBeenCalledWith("Agent ID copied to clipboard", "success"); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx b/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx index b9ac0c4e04..0c71a0f6ac 100644 --- a/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx @@ -17,6 +17,8 @@ const issueContext = { describe("AgentErrorDetailsModal", () => { const originalClipboard = navigator.clipboard; + const originalExecCommand = document.execCommand; + const originalSecureContext = window.isSecureContext; const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); beforeEach(() => { Object.defineProperty(navigator, "clipboard", { @@ -28,6 +30,8 @@ describe("AgentErrorDetailsModal", () => { afterEach(() => { Object.defineProperty(navigator, "clipboard", { value: originalClipboard, configurable: true }); + Object.defineProperty(document, "execCommand", { value: originalExecCommand, configurable: true }); + Object.defineProperty(window, "isSecureContext", { value: originalSecureContext, configurable: true }); }); it("does not render when closed", () => { @@ -52,6 +56,20 @@ describe("AgentErrorDetailsModal", () => { }); }); + it("copies error text through execCommand when Clipboard API is unavailable", async () => { + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + Object.defineProperty(window, "isSecureContext", { value: false, configurable: true }); + Object.defineProperty(document, "execCommand", { value: execCommand, configurable: true }); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Copy error to clipboard" })); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(screen.getByRole("button", { name: "Copied error to clipboard" })).toBeInTheDocument(); + }); + it("gates backdrop dismissal behind the global modal dismiss preference", () => { const disabledClose = vi.fn(); const { unmount } = render(); diff --git a/packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx b/packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx new file mode 100644 index 0000000000..3ec6f76b8f --- /dev/null +++ b/packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CliBinaryPanel } from "../CliBinaryPanel"; +import { fetchFnBinaryStatus, installFnBinary } from "../../api/legacy"; + +vi.mock("../../api/legacy", () => ({ + fetchFnBinaryStatus: vi.fn(), + installFnBinary: vi.fn(), +})); + +const originalClipboard = navigator.clipboard; +const originalExecCommand = document.execCommand; + +function mockClipboardFallback(result: boolean) { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const execCommand = vi.fn().mockReturnValue(result); + Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand }); + return execCommand; +} + +function mockStatus() { + vi.mocked(fetchFnBinaryStatus).mockResolvedValue({ + binary: { binary: "fn", installed: false, path: null, version: null }, + expectedVersion: "1.2.3", + state: "missing", + install: { + npm: "npm install -g @runfusion/fusion", + curl: "curl -fsSL https://example.test/install.sh | sh", + }, + }); + vi.mocked(installFnBinary).mockResolvedValue({} as never); +} + +describe("CliBinaryPanel clipboard copy", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStatus(); + }); + + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard }); + Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand }); + }); + + it("shows Copied after the execCommand fallback succeeds", async () => { + const execCommand = mockClipboardFallback(true); + render(); + await screen.findByText("npm install -g @runfusion/fusion"); + + await userEvent.click(screen.getAllByRole("button", { name: "Copy" })[0]); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + }); + + it("stays silent and does not show Copied when both clipboard paths fail", async () => { + const execCommand = mockClipboardFallback(false); + render(); + await screen.findByText("npm install -g @runfusion/fusion"); + + await userEvent.click(screen.getAllByRole("button", { name: "Copy" })[0]); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2); + expect(screen.queryByRole("button", { name: "Copied" })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index 26b3a7e2b3..7d7ce98209 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -77,6 +77,16 @@ vi.mock("../../api", async () => { }; }); +const originalClipboard = navigator.clipboard; +const originalExecCommand = document.execCommand; + +function mockClipboardFallback(result: boolean) { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const execCommand = vi.fn().mockReturnValue(result); + Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand }); + return execCommand; +} + const mockConfirm = vi.fn(); vi.mock("../../hooks/useConfirm", () => ({ @@ -190,6 +200,8 @@ const mockTasks: Task[] = [ describe("GitManagerModal", () => { afterEach(() => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard }); + Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand }); vi.useRealTimers(); }); @@ -582,6 +594,17 @@ describe("GitManagerModal", () => { }); }); + it("copies status commit hash through execCommand when Clipboard API is unavailable", async () => { + const execCommand = mockClipboardFallback(true); + render(); + + await waitFor(() => expect(screen.getByText("main")).toBeInTheDocument()); + fireEvent.click(screen.getByTitle("Copy short commit hash")); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(mockAddToast).toHaveBeenCalledWith("Copied commit hash", "success"); + }); + it("shows dirty status when working tree is modified", async () => { (fetchGitStatus as any).mockResolvedValue({ branch: "main", diff --git a/packages/dashboard/app/components/__tests__/SecretsView.test.tsx b/packages/dashboard/app/components/__tests__/SecretsView.test.tsx index 8c39588fa9..0a02c5dbc1 100644 --- a/packages/dashboard/app/components/__tests__/SecretsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/SecretsView.test.tsx @@ -29,6 +29,21 @@ function removeAllCss() { document.head.querySelector('[data-test-all-app-css="true"]')?.remove(); } +const originalClipboard = navigator.clipboard; +const originalExecCommand = document.execCommand; + +function mockClipboardFallback(result: boolean) { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const execCommand = vi.fn().mockReturnValue(result); + Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand }); + return execCommand; +} + +function restoreClipboardMocks() { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard }); + Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand }); +} + function expectVisibleActionIcon(button: HTMLElement) { const svg = button.querySelector("svg"); expect(svg).not.toBeNull(); @@ -57,6 +72,7 @@ describe("SecretsView", () => { }); afterEach(() => { + restoreClipboardMocks(); removeAllCss(); delete document.documentElement.dataset.theme; }); @@ -238,6 +254,72 @@ describe("SecretsView", () => { expectVisibleActionIcon(screen.getByRole("button", { name: "Delete" })); }); + it("copies revealed secrets through the execCommand fallback when Clipboard API is unavailable", async () => { + const execCommand = mockClipboardFallback(true); + const addToast = vi.fn(); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + mockJsonResponse({ + ok: true, + body: { + secrets: [ + { id: "secret-1", key: "VISIBLE", scope: "project", description: null, accessPolicy: "prompt", envExportable: false, envExportKey: null, lastReadAt: null }, + ], + }, + }), + ) + .mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } })) + .mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { key: "VISIBLE", value: "super-secret-value" } })), + ); + + render(); + await screen.findByText("VISIBLE"); + await userEvent.click(screen.getByRole("button", { name: "Reveal" })); + expect(await screen.findByText("super-secret-value")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Copy" })); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(addToast).toHaveBeenCalledWith("Copied", "success"); + expect(screen.getByRole("button", { name: "Copy" }).querySelector(".lucide-check")).toBeInTheDocument(); + }); + + it("shows a failure toast without marking a secret copied when both clipboard paths fail", async () => { + const execCommand = mockClipboardFallback(false); + const addToast = vi.fn(); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + mockJsonResponse({ + ok: true, + body: { + secrets: [ + { id: "secret-1", key: "VISIBLE", scope: "project", description: null, accessPolicy: "prompt", envExportable: false, envExportKey: null, lastReadAt: null }, + ], + }, + }), + ) + .mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } })) + .mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { key: "VISIBLE", value: "super-secret-value" } })), + ); + + render(); + await screen.findByText("VISIBLE"); + await userEvent.click(screen.getByRole("button", { name: "Reveal" })); + expect(await screen.findByText("super-secret-value")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Copy" })); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(addToast).toHaveBeenCalledWith("Failed to copy secret", "error"); + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + }); + it("revealed secret can be hidden again from the row toggle", async () => { const fetchMock = vi .fn() diff --git a/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx b/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx index 81f0bee6c9..d9cc812e08 100644 --- a/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx @@ -1,10 +1,20 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import StashConflictModal from "../StashConflictModal"; import { ApiRequestError } from "../../api"; +const originalClipboard = navigator.clipboard; +const originalExecCommand = document.execCommand; + +function mockClipboardFallback(result: boolean) { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const execCommand = vi.fn().mockReturnValue(result); + Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand }); + return execCommand; +} + const mocked = vi.hoisted(() => ({ api: vi.fn(), openFile: vi.fn(), @@ -41,6 +51,11 @@ function renderModal(overrides: Partial { + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard }); + Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand }); + }); + beforeEach(() => { mocked.api.mockReset(); mocked.openFile.mockReset(); @@ -148,6 +163,14 @@ describe("StashConflictModal", () => { await waitFor(() => expect(mocked.writeText).toHaveBeenCalledWith("1234567890abcdef")); }); + it("copies the stash sha through execCommand when Clipboard API is unavailable", async () => { + const execCommand = mockClipboardFallback(true); + renderModal(); + fireEvent.click(screen.getByRole("button", { name: "Copy stash reference" })); + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(screen.getByRole("status")).toHaveTextContent("Stash SHA copied."); + }); + it("supports Escape close, initial focus, focus return, and tab wrapping", async () => { const user = userEvent.setup(); const onClose = vi.fn(); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 3795a23019..8ea5bfccc4 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -35,6 +35,10 @@ "types": "./app/utils/taskStuck.ts", "import": "./app/utils/taskStuck.ts" }, + "./app/utils/copyToClipboard": { + "types": "./app/utils/copyToClipboard.ts", + "import": "./app/utils/copyToClipboard.ts" + }, "./app/utils/projectStorage": { "types": "./app/utils/projectStorage.ts", "import": "./app/utils/projectStorage.ts" diff --git a/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx b/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx index 8173294b3f..76748131a7 100644 --- a/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx +++ b/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { copyTextToClipboard } from "@fusion/dashboard/app/utils/copyToClipboard"; import { getShareBlocks } from "../api.js"; import type { ReportRecord } from "../types.js"; import type { ShareBlocks } from "../../share-blocks.js"; @@ -35,7 +36,9 @@ export function ShareBlocksPanel({ report }: { report: ReportRecord }) {