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:
7
.changeset/fn-7885-clipboard-migration.md
Normal file
7
.changeset/fn-7885-clipboard-migration.md
Normal 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.
|
||||||
@@ -36,6 +36,7 @@ import { AgentTaskBadge } from "./AgentTaskBadge";
|
|||||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||||
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
|
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
|
||||||
import { useFavorites } from "../hooks/useFavorites";
|
import { useFavorites } from "../hooks/useFavorites";
|
||||||
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple className utility - joins class names conditionally
|
* Simple className utility - joins class names conditionally
|
||||||
@@ -642,10 +643,19 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
|||||||
return getAgentHealthStatus(agent);
|
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) {
|
if (agent) {
|
||||||
navigator.clipboard.writeText(agent.id);
|
const copied = await copyTextToClipboard(agent.id);
|
||||||
addToast(t("agents.idCopied", "Agent ID copied to clipboard"), "success");
|
addToast(
|
||||||
|
copied
|
||||||
|
? t("agents.idCopied", "Agent ID copied to clipboard")
|
||||||
|
: t("agents.idCopyFailed", "Failed to copy agent ID"),
|
||||||
|
copied ? "success" : "error"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react";
|
import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
|
|
||||||
const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
|
const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
|
||||||
|
|
||||||
@@ -78,7 +79,9 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
onClick={() => {
|
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);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type FnBinaryInstallResult,
|
type FnBinaryInstallResult,
|
||||||
type FnBinaryStatus,
|
type FnBinaryStatus,
|
||||||
} from "../api/legacy";
|
} from "../api/legacy";
|
||||||
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
import "./CliBinaryPanel.css";
|
import "./CliBinaryPanel.css";
|
||||||
|
|
||||||
interface Props {
|
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) => {
|
const copy = useCallback(async (label: string, value: string) => {
|
||||||
try {
|
const copiedToClipboard = await copyTextToClipboard(value);
|
||||||
await navigator.clipboard.writeText(value);
|
if (!copiedToClipboard) return;
|
||||||
setCopied(label);
|
setCopied(label);
|
||||||
setTimeout(() => setCopied((c) => (c === label ? null : c)), 1500);
|
setTimeout(() => setCopied((c) => (c === label ? null : c)), 1500);
|
||||||
} catch {
|
|
||||||
// Clipboard API unavailable — leave button silent rather than throwing.
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stateMeta = status ? getStateLabel(status.state) : null;
|
const stateMeta = status ? getStateLabel(status.state) : null;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
|||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
import type {
|
import type {
|
||||||
GitStatus,
|
GitStatus,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
@@ -149,14 +150,23 @@ function FileStatusBadge({ status }: { status: GitFileChange["status"] }) {
|
|||||||
return <span className={`gm-file-badge gm-file-badge-${status}`}>{label}</span>;
|
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) {
|
function useCopyToClipboard(addToast: (msg: string, type?: ToastType) => void) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (text: string, label?: string) => {
|
async (text: string, label?: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
const copied = await copyTextToClipboard(text);
|
||||||
addToast(label ? t("git.copiedLabel", "Copied {{label}}", { label }) : t("git.copiedToClipboard", "Copied to clipboard"), "success");
|
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 {
|
} catch {
|
||||||
addToast(t("git.failedToCopy", "Failed to copy"), "error");
|
addToast(t("git.failedToCopy", "Failed to copy"), "error");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Copy, Check } from "lucide-react";
|
import { Copy, Check } from "lucide-react";
|
||||||
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
import "./LoginInstructions.css";
|
import "./LoginInstructions.css";
|
||||||
|
|
||||||
interface LoginInstructionsProps {
|
interface LoginInstructionsProps {
|
||||||
@@ -49,14 +50,14 @@ export function LoginInstructions({ instructions, "data-testid": testId }: Login
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
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 handleCopy = useCallback(async () => {
|
||||||
const textToCopy = deviceCode ?? instructions;
|
const textToCopy = deviceCode ?? instructions;
|
||||||
try {
|
const copiedToClipboard = await copyTextToClipboard(textToCopy);
|
||||||
await navigator.clipboard.writeText(textToCopy);
|
if (copiedToClipboard) markCopied();
|
||||||
markCopied();
|
|
||||||
} catch {
|
|
||||||
// Ignore copy failures
|
|
||||||
}
|
|
||||||
}, [deviceCode, instructions, markCopied]);
|
}, [deviceCode, instructions, markCopied]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,12 +69,8 @@ export function LoginInstructions({ instructions, "data-testid": testId }: Login
|
|||||||
}
|
}
|
||||||
|
|
||||||
const autoCopy = async () => {
|
const autoCopy = async () => {
|
||||||
try {
|
const copiedToClipboard = await copyTextToClipboard(deviceCode);
|
||||||
await navigator.clipboard.writeText(deviceCode);
|
if (copiedToClipboard) markCopied();
|
||||||
markCopied();
|
|
||||||
} catch {
|
|
||||||
// Ignore copy failures
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void autoCopy();
|
void autoCopy();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { usePrChecksStream } from "../hooks/usePrChecksStream";
|
|||||||
import { PrChecksList } from "./PrChecksList";
|
import { PrChecksList } from "./PrChecksList";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||||
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
import "./PrPanel.css";
|
import "./PrPanel.css";
|
||||||
|
|
||||||
interface PrPanelProps {
|
interface PrPanelProps {
|
||||||
@@ -289,7 +290,13 @@ function PrCard({
|
|||||||
{conflictDiagnostics && (prInfo.mergeable === "conflicting" || hasConflictBlockingReason) ? (
|
{conflictDiagnostics && (prInfo.mergeable === "conflicting" || hasConflictBlockingReason) ? (
|
||||||
<div className="pr-conflict-section">
|
<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>
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, ChevronDown, ChevronRight, Copy, Eye, EyeOff, Lock, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Check, ChevronDown, ChevronRight, Copy, Eye, EyeOff, Lock, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { ViewHeader } from "./ViewHeader";
|
import { ViewHeader } from "./ViewHeader";
|
||||||
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
|
|
||||||
type ToastKind = "info" | "success" | "error";
|
type ToastKind = "info" | "success" | "error";
|
||||||
type SecretScope = "project" | "global";
|
type SecretScope = "project" | "global";
|
||||||
@@ -254,10 +255,18 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
|||||||
revealTimersRef.current.set(secret.id, timer);
|
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 copySecret = async (secret: SecretRecord) => {
|
||||||
const revealed = revealedValues[secret.id];
|
const revealed = revealedValues[secret.id];
|
||||||
if (!revealed) return;
|
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);
|
setCopiedId(secret.id);
|
||||||
addToast?.(t("secrets.copied", "Copied"), "success");
|
addToast?.(t("secrets.copied", "Copied"), "success");
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Copy } from "lucide-react";
|
import { Copy } from "lucide-react";
|
||||||
import { ApiRequestError, api } from "../api";
|
import { ApiRequestError, api } from "../api";
|
||||||
import { useFileBrowser } from "../context/FileBrowserContext";
|
import { useFileBrowser } from "../context/FileBrowserContext";
|
||||||
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
import "./StashConflictModal.css";
|
import "./StashConflictModal.css";
|
||||||
|
|
||||||
interface ResolveResponse {
|
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 () => {
|
const copyRef = async () => {
|
||||||
try {
|
const copied = await copyTextToClipboard(stashSha);
|
||||||
await navigator.clipboard.writeText(stashSha);
|
setCopyState(copied ? "copied" : "failed");
|
||||||
setCopyState("copied");
|
|
||||||
} catch {
|
|
||||||
setCopyState("failed");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,8 @@ const issueContext = {
|
|||||||
|
|
||||||
describe("AgentErrorDetailsModal", () => {
|
describe("AgentErrorDetailsModal", () => {
|
||||||
const originalClipboard = navigator.clipboard;
|
const originalClipboard = navigator.clipboard;
|
||||||
|
const originalExecCommand = document.execCommand;
|
||||||
|
const originalSecureContext = window.isSecureContext;
|
||||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.defineProperty(navigator, "clipboard", {
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
@@ -28,6 +30,8 @@ describe("AgentErrorDetailsModal", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.defineProperty(navigator, "clipboard", { value: originalClipboard, configurable: true });
|
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", () => {
|
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", () => {
|
it("gates backdrop dismissal behind the global modal dismiss preference", () => {
|
||||||
const disabledClose = vi.fn();
|
const disabledClose = vi.fn();
|
||||||
const { unmount } = render(<AgentErrorDetailsModal open={true} onClose={disabledClose} errorText="boom" issueContext={issueContext} />);
|
const { unmount } = render(<AgentErrorDetailsModal open={true} onClose={disabledClose} errorText="boom" issueContext={issueContext} />);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
const mockConfirm = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../hooks/useConfirm", () => ({
|
vi.mock("../../hooks/useConfirm", () => ({
|
||||||
@@ -190,6 +200,8 @@ const mockTasks: Task[] = [
|
|||||||
|
|
||||||
describe("GitManagerModal", () => {
|
describe("GitManagerModal", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
|
||||||
|
Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
|
||||||
vi.useRealTimers();
|
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 () => {
|
it("shows dirty status when working tree is modified", async () => {
|
||||||
(fetchGitStatus as any).mockResolvedValue({
|
(fetchGitStatus as any).mockResolvedValue({
|
||||||
branch: "main",
|
branch: "main",
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ function removeAllCss() {
|
|||||||
document.head.querySelector('[data-test-all-app-css="true"]')?.remove();
|
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) {
|
function expectVisibleActionIcon(button: HTMLElement) {
|
||||||
const svg = button.querySelector("svg");
|
const svg = button.querySelector("svg");
|
||||||
expect(svg).not.toBeNull();
|
expect(svg).not.toBeNull();
|
||||||
@@ -57,6 +72,7 @@ describe("SecretsView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
restoreClipboardMocks();
|
||||||
removeAllCss();
|
removeAllCss();
|
||||||
delete document.documentElement.dataset.theme;
|
delete document.documentElement.dataset.theme;
|
||||||
});
|
});
|
||||||
@@ -238,6 +254,72 @@ describe("SecretsView", () => {
|
|||||||
expectVisibleActionIcon(screen.getByRole("button", { name: "Delete" }));
|
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 () => {
|
it("revealed secret can be hidden again from the row toggle", async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { ComponentProps } from "react";
|
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 StashConflictModal from "../StashConflictModal";
|
||||||
import { ApiRequestError } from "../../api";
|
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(() => ({
|
const mocked = vi.hoisted(() => ({
|
||||||
api: vi.fn(),
|
api: vi.fn(),
|
||||||
openFile: vi.fn(),
|
openFile: vi.fn(),
|
||||||
@@ -41,6 +51,11 @@ function renderModal(overrides: Partial<ComponentProps<typeof StashConflictModal
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("StashConflictModal", () => {
|
describe("StashConflictModal", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
|
||||||
|
Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocked.api.mockReset();
|
mocked.api.mockReset();
|
||||||
mocked.openFile.mockReset();
|
mocked.openFile.mockReset();
|
||||||
@@ -148,6 +163,14 @@ describe("StashConflictModal", () => {
|
|||||||
await waitFor(() => expect(mocked.writeText).toHaveBeenCalledWith("1234567890abcdef"));
|
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 () => {
|
it("supports Escape close, initial focus, focus return, and tab wrapping", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
"types": "./app/utils/taskStuck.ts",
|
"types": "./app/utils/taskStuck.ts",
|
||||||
"import": "./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": {
|
"./app/utils/projectStorage": {
|
||||||
"types": "./app/utils/projectStorage.ts",
|
"types": "./app/utils/projectStorage.ts",
|
||||||
"import": "./app/utils/projectStorage.ts"
|
"import": "./app/utils/projectStorage.ts"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { copyTextToClipboard } from "@fusion/dashboard/app/utils/copyToClipboard";
|
||||||
import { getShareBlocks } from "../api.js";
|
import { getShareBlocks } from "../api.js";
|
||||||
import type { ReportRecord } from "../types.js";
|
import type { ReportRecord } from "../types.js";
|
||||||
import type { ShareBlocks } from "../../share-blocks.js";
|
import type { ShareBlocks } from "../../share-blocks.js";
|
||||||
@@ -35,7 +36,9 @@ export function ShareBlocksPanel({ report }: { report: ReportRecord }) {
|
|||||||
</div>
|
</div>
|
||||||
<textarea className="input share-blocks-panel__content" readOnly value={value} />
|
<textarea className="input share-blocks-panel__content" readOnly value={value} />
|
||||||
<button className="btn btn-sm" onClick={async () => {
|
<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);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1000);
|
setTimeout(() => setCopied(false), 1000);
|
||||||
}}>{copied ? "Copied" : "Copy"}</button>
|
}}>{copied ? "Copied" : "Copy"}</button>
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
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";
|
import { ShareBlocksPanel } from "../ShareBlocksPanel.js";
|
||||||
|
|
||||||
|
const originalClipboard = navigator.clipboard;
|
||||||
|
const originalExecCommand = document.execCommand;
|
||||||
|
|
||||||
const getShareBlocks = vi.fn();
|
const getShareBlocks = vi.fn();
|
||||||
vi.mock("../../api.js", () => ({ getShareBlocks: (...args: unknown[]) => getShareBlocks(...args) }));
|
vi.mock("../../api.js", () => ({ getShareBlocks: (...args: unknown[]) => getShareBlocks(...args) }));
|
||||||
|
|
||||||
describe("ShareBlocksPanel", () => {
|
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 () => {
|
it("renders tabs and copies selected block", async () => {
|
||||||
getShareBlocks.mockResolvedValue({ plainText: "a", markdown: "b", slack: "c", emailHtml: "d" });
|
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} />);
|
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
|
||||||
await screen.findByText("Plain Text");
|
await screen.findByText("Plain Text");
|
||||||
fireEvent.click(screen.getByText("Markdown"));
|
fireEvent.click(screen.getByText("Markdown"));
|
||||||
@@ -16,6 +25,36 @@ describe("ShareBlocksPanel", () => {
|
|||||||
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith("b"));
|
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 () => {
|
it("shows locked message on 409", async () => {
|
||||||
getShareBlocks.mockRejectedValue(new Error("409 Conflict"));
|
getShareBlocks.mockRejectedValue(new Error("409 Conflict"));
|
||||||
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
|
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
"@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/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)),
|
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user