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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 17:54:21 -07:00
parent 3a37f48e78
commit 6ea53966f6
19 changed files with 402 additions and 38 deletions

View File

@@ -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.

View File

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

View File

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

View File

@@ -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;

View File

@@ -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 <span className={`gm-file-badge gm-file-badge-${status}`}>{label}</span>;
}
/** 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");
}

View File

@@ -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();

View File

@@ -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) ? (
<div className="pr-conflict-section">
<div className="pr-conflict-section__header"><button type="button" className="btn btn-sm" onClick={() => setConflictsExpanded((value) => !value)}>{conflictsExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />} {t("git.conflictsButton", "Conflicts")}</button><button type="button" className="btn btn-sm" onClick={() => void handleRefresh()} disabled={isRefreshing}>{t("git.reCheckConflicts", "Re-check conflicts")}</button></div>
{conflictsExpanded ? <><ul className="pr-conflict-files">{conflictDiagnostics.conflictingFiles.map((file) => <li key={file}>{linkifyFilePaths(file, { keyPrefix: `pr-conflict-${file}` })}</li>)}</ul><pre className="pr-conflict-commands"><code>{conflictDiagnostics.suggestedCommands.join("\n")}</code></pre><div className="pr-conflict-section__header"><button type="button" className="btn btn-sm" onClick={async () => { await navigator.clipboard.writeText(conflictDiagnostics.suggestedCommands.join("\n")); setCopiedConflicts(true); setTimeout(() => setCopiedConflicts(false), 1200); }}>{copiedConflicts ? t("git.copiedButton", "Copied") : t("git.copyButton", "Copy")}</button><span className="pr-panel-tone-muted">{t("git.capturedAt", "Captured:")} {new Date(conflictDiagnostics.capturedAt).toLocaleString()}</span></div></> : null}
{conflictsExpanded ? <><ul className="pr-conflict-files">{conflictDiagnostics.conflictingFiles.map((file) => <li key={file}>{linkifyFilePaths(file, { keyPrefix: `pr-conflict-${file}` })}</li>)}</ul><pre className="pr-conflict-commands"><code>{conflictDiagnostics.suggestedCommands.join("\n")}</code></pre><div className="pr-conflict-section__header"><button type="button" className="btn btn-sm" onClick={async () => {
/* 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 copied = await copyTextToClipboard(conflictDiagnostics.suggestedCommands.join("\n"));
if (!copied) return;
setCopiedConflicts(true);
setTimeout(() => setCopiedConflicts(false), 1200);
}}>{copiedConflicts ? t("git.copiedButton", "Copied") : t("git.copyButton", "Copy")}</button><span className="pr-panel-tone-muted">{t("git.capturedAt", "Captured:")} {new Date(conflictDiagnostics.capturedAt).toLocaleString()}</span></div></> : null}
</div>
) : null}

View File

@@ -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(() => {

View File

@@ -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 (

View File

@@ -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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={addToast} />);
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");
});
});

View File

@@ -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(<AgentErrorDetailsModal open={true} onClose={vi.fn()} errorText="copy me" issueContext={issueContext} />);
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(<AgentErrorDetailsModal open={true} onClose={disabledClose} errorText="boom" issueContext={issueContext} />);

View File

@@ -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(<CliBinaryPanel />);
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(<CliBinaryPanel />);
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();
});
});

View File

@@ -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(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
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",

View File

@@ -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(<SecretsView addToast={addToast} />);
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(<SecretsView addToast={addToast} />);
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()

View File

@@ -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<ComponentProps<typeof StashConflictModal
}
describe("StashConflictModal", () => {
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();

View File

@@ -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"

View File

@@ -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 }) {
</div>
<textarea className="input share-blocks-panel__content" readOnly value={value} />
<button className="btn btn-sm" onClick={async () => {
await navigator.clipboard.writeText(value);
/* 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 copiedToClipboard = await copyTextToClipboard(value);
if (!copiedToClipboard) return;
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}}>{copied ? "Copied" : "Copy"}</button>

View File

@@ -1,14 +1,23 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ShareBlocksPanel } from "../ShareBlocksPanel.js";
const originalClipboard = navigator.clipboard;
const originalExecCommand = document.execCommand;
const getShareBlocks = vi.fn();
vi.mock("../../api.js", () => ({ getShareBlocks: (...args: unknown[]) => getShareBlocks(...args) }));
describe("ShareBlocksPanel", () => {
afterEach(() => {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
getShareBlocks.mockReset();
});
it("renders tabs and copies selected block", async () => {
getShareBlocks.mockResolvedValue({ plainText: "a", markdown: "b", slack: "c", emailHtml: "d" });
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } });
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
await screen.findByText("Plain Text");
fireEvent.click(screen.getByText("Markdown"));
@@ -16,6 +25,36 @@ describe("ShareBlocksPanel", () => {
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith("b"));
});
it("copies selected block through execCommand when Clipboard API is unavailable", async () => {
getShareBlocks.mockResolvedValue({ plainText: "a", markdown: "b", slack: "c", emailHtml: "d" });
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
await screen.findByText("Plain Text");
fireEvent.click(screen.getByText("Markdown"));
fireEvent.click(screen.getByText("Copy"));
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
expect(screen.getByText("Copied")).toBeInTheDocument();
});
it("does not show copied when both clipboard paths fail", async () => {
getShareBlocks.mockResolvedValue({ plainText: "a", markdown: "b", slack: "c", emailHtml: "d" });
const execCommand = vi.fn().mockReturnValue(false);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
await screen.findByText("Plain Text");
fireEvent.click(screen.getByText("Copy"));
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
expect(screen.getByText("Copy")).toBeInTheDocument();
expect(screen.queryByText("Copied")).not.toBeInTheDocument();
});
it("shows locked message on 409", async () => {
getShareBlocks.mockRejectedValue(new Error("409 Conflict"));
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);

View File

@@ -15,6 +15,8 @@ export default defineConfig({
resolve: {
alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
// FNXC:Clipboard 2026-07-12-00:00: The reports plugin imports the dashboard clipboard helper through its package subpath export; keep this exact alias ahead of the package root alias so vitest does not collapse the subpath to src/index.ts.
"@fusion/dashboard/app/utils/copyToClipboard": fileURLToPath(new URL("../../packages/dashboard/app/utils/copyToClipboard.ts", import.meta.url)),
"@fusion/dashboard": fileURLToPath(new URL("../../packages/dashboard/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
},