From 6ea53966f64742db9b61ae85dd8aecec2c04513a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 17:54:21 -0700 Subject: [PATCH] FN-7885: migrate remaining direct clipboard callers to shared copyTextToClipboard helper Replaces the last direct navigator.clipboard.writeText() call sites across dashboard components and the reports plugin with the shared copyTextToClipboard helper, fixing copy actions that crashed or silently failed on non-secure origins (HTTP/mobile). - Migrated AgentDetailView, AgentErrorDetailsModal, CliBinaryPanel, GitManagerModal, LoginInstructions, PrPanel, SecretsView, and StashConflictModal to use copyTextToClipboard (secure-context guard + execCommand fallback, boolean result handling) instead of calling navigator.clipboard directly. - Migrated the fusion-plugin-reports ShareBlocksPanel to the same helper and added a vitest alias so the plugin's subpath import resolves to the dashboard's copyToClipboard util instead of collapsing to its package root. - Added ./app/utils/copyToClipboard subpath export to @fusion/dashboard's package.json. - Added/extended tests covering copy success, fallback, and failure paths for AgentDetailView, CliBinaryPanel, AgentErrorDetailsModal, GitManagerModal, SecretsView, StashConflictModal, and ShareBlocksPanel. - Added a patch changeset documenting the fix for @runfusion/fusion. Files changed: .changeset/fn-7885-clipboard-migration.md | 7 ++ packages/dashboard/app/components/AgentDetailView.tsx | 16 ++++- packages/dashboard/app/components/AgentErrorDetailsModal.tsx | 5 +- packages/dashboard/app/components/CliBinaryPanel.tsx | 16 +++-- packages/dashboard/app/components/GitManagerModal.tsx | 16 ++++- packages/dashboard/app/components/LoginInstructions.tsx | 21 +++--- packages/dashboard/app/components/PrPanel.tsx | 9 ++- packages/dashboard/app/components/SecretsView.tsx | 11 ++- packages/dashboard/app/components/StashConflictModal.tsx | 13 ++-- packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx | 56 +++++++++++++++ packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx | 18 +++++ packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx | 68 ++++++++++++++++++ packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx | 23 ++++++ packages/dashboard/app/components/__tests__/SecretsView.test.tsx | 82 ++++++++++++++++++++++ packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx | 25 ++++++- packages/dashboard/package.json | 4 ++ plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx | 5 +- plugins/fusion-plugin-reports/src/dashboard/components/__tests__/ShareBlocksPanel.test.tsx | 43 +++++++++++- plugins/fusion-plugin-reports/vitest.config.ts | 2 + 19 files changed, 402 insertions(+), 38 deletions(-) Fusion-Task-Id: FN-7885 Fusion-Task-Lineage: 122a54ea-962b-4ae1-98ac-c28e03f4f8ca Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7885-clipboard-migration.md | 7 ++ .../app/components/AgentDetailView.tsx | 16 +++- .../app/components/AgentErrorDetailsModal.tsx | 5 +- .../app/components/CliBinaryPanel.tsx | 16 ++-- .../app/components/GitManagerModal.tsx | 16 +++- .../app/components/LoginInstructions.tsx | 21 ++--- packages/dashboard/app/components/PrPanel.tsx | 9 +- .../dashboard/app/components/SecretsView.tsx | 11 ++- .../app/components/StashConflictModal.tsx | 13 +-- .../__tests__/AgentDetailView.copy.test.tsx | 56 +++++++++++++ .../__tests__/AgentErrorDetailsModal.test.tsx | 18 ++++ .../__tests__/CliBinaryPanel.copy.test.tsx | 68 +++++++++++++++ .../__tests__/GitManagerModal.test.tsx | 23 ++++++ .../components/__tests__/SecretsView.test.tsx | 82 +++++++++++++++++++ .../__tests__/StashConflictModal.test.tsx | 25 +++++- packages/dashboard/package.json | 4 + .../dashboard/components/ShareBlocksPanel.tsx | 5 +- .../__tests__/ShareBlocksPanel.test.tsx | 43 +++++++++- .../fusion-plugin-reports/vitest.config.ts | 2 + 19 files changed, 402 insertions(+), 38 deletions(-) create mode 100644 .changeset/fn-7885-clipboard-migration.md create mode 100644 packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx 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.conflictingFiles.map((file) =>
  • {linkifyFilePaths(file, { keyPrefix: `pr-conflict-${file}` })}
  • )}
{conflictDiagnostics.suggestedCommands.join("\n")}
{t("git.capturedAt", "Captured:")} {new Date(conflictDiagnostics.capturedAt).toLocaleString()}
: null} + {conflictsExpanded ? <>
    {conflictDiagnostics.conflictingFiles.map((file) =>
  • {linkifyFilePaths(file, { keyPrefix: `pr-conflict-${file}` })}
  • )}
{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 }) {