diff --git a/.changeset/fn-7882-copy-diagnostics-fix.md b/.changeset/fn-7882-copy-diagnostics-fix.md new file mode 100644 index 0000000000..b0c6807762 --- /dev/null +++ b/.changeset/fn-7882-copy-diagnostics-fix.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix "Copy diagnostics" crash on non-secure origins (mobile/HTTP). +category: fix +dev: Command Center System tab now routes diagnostics copy through copyTextToClipboard (secure-context guard + execCommand fallback) instead of navigator.clipboard.writeText, which was undefined outside secure contexts. diff --git a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx index 29b922de92..7ceae7ef50 100644 --- a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import "@testing-library/jest-dom"; import { CommandCenter } from "../CommandCenter"; @@ -9,6 +9,7 @@ const apiMock = vi.fn(); const mockFetchSystemInfo = vi.fn(); const mockFetchCurrentSystemRebuild = vi.fn(); const mockFetchSystemStats = vi.fn(); +const mockFetchSystemLogs = vi.fn(); const mockFetchNodeSystemStats = vi.fn(); const mockFetchGlobalSettings = vi.fn(); const mockFetchNodes = vi.fn(); @@ -26,7 +27,7 @@ vi.mock("../../../api/legacy", () => ({ fetchDashboardHealth: vi.fn().mockResolvedValue({ ok: true }), fetchCurrentSystemRebuild: (...args: unknown[]) => mockFetchCurrentSystemRebuild(...args), fetchSystemInfo: (...args: unknown[]) => mockFetchSystemInfo(...args), - fetchSystemLogs: vi.fn().mockResolvedValue({ entries: [] }), + fetchSystemLogs: (...args: unknown[]) => mockFetchSystemLogs(...args), reloadAllSystemPlugins: vi.fn().mockResolvedValue({ ok: true }), requestSystemRestart: vi.fn().mockResolvedValue({ ok: true }), restartAllSystemAgents: vi.fn().mockResolvedValue({ ok: true }), @@ -114,10 +115,39 @@ function systemStatsFixture() { } describe("SystemControlsArea layout integration", () => { + const originalClipboard = navigator.clipboard; + const originalExecCommand = document.execCommand; + + function mockClipboard(value: unknown) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value, + }); + } + + function mockExecCommand(result: boolean) { + const execCommand = vi.fn().mockReturnValue(result); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + return execCommand; + } + + async function renderSystemTab(addToast = vi.fn()) { + render(); + fireEvent.click(screen.getByTestId("command-center-tab-system")); + const diagnosticsCard = await screen.findByTestId("cc-syscontrol-diagnostics"); + return { addToast, diagnosticsCard }; + } + beforeEach(() => { vi.clearAllMocks(); apiMock.mockImplementation((path: string) => Promise.resolve(emptyOverviewResponse(path))); mockFetchSystemInfo.mockResolvedValue(systemInfoFixture()); + mockFetchSystemLogs.mockResolvedValue({ + entries: [{ timestamp: "2026-07-12T00:00:00.000Z", level: "info", message: "ready" }], + }); mockFetchCurrentSystemRebuild.mockResolvedValue({ job: null }); mockFetchSystemStats.mockResolvedValue(systemStatsFixture()); mockFetchNodeSystemStats.mockResolvedValue(systemStatsFixture()); @@ -125,6 +155,18 @@ describe("SystemControlsArea layout integration", () => { mockFetchNodes.mockResolvedValue([]); }); + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: originalExecCommand, + }); + document.body.innerHTML = ""; + }); + it("wraps System controls, Server logs, and Live system health in the shared gap owner", async () => { render(); @@ -162,6 +204,45 @@ describe("SystemControlsArea layout integration", () => { expect(refresh.parentElement).toBe(header); }); + it("copies diagnostics through the execCommand fallback when Clipboard API is unavailable", async () => { + mockClipboard(undefined); + const execCommand = mockExecCommand(true); + const { addToast, diagnosticsCard } = await renderSystemTab(); + + fireEvent.click(within(diagnosticsCard).getByRole("button", { name: "Copy" })); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(addToast).toHaveBeenCalledWith("Diagnostics copied to clipboard", "success"); + expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("writeText"), "error"); + }); + + it("copies diagnostics through navigator.clipboard.writeText in secure contexts", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + mockClipboard({ writeText }); + const { addToast, diagnosticsCard } = await renderSystemTab(); + + fireEvent.click(within(diagnosticsCard).getByRole("button", { name: "Copy" })); + + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + expect(writeText.mock.calls[0]?.[0]).toContain('"recentLogs"'); + expect(addToast).toHaveBeenCalledWith("Diagnostics copied to clipboard", "success"); + expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("writeText"), "error"); + }); + + it("shows a failure toast when diagnostics cannot be copied by either clipboard path", async () => { + mockFetchSystemInfo.mockResolvedValue({ ...systemInfoFixture(), logsSupported: false }); + mockClipboard(undefined); + const execCommand = mockExecCommand(false); + const { addToast, diagnosticsCard } = await renderSystemTab(); + + fireEvent.click(within(diagnosticsCard).getByRole("button", { name: "Copy" })); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(addToast).toHaveBeenCalledWith("Could not copy diagnostics to clipboard", "error"); + expect(addToast).not.toHaveBeenCalledWith("Diagnostics copied to clipboard", "success"); + expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("writeText"), "error"); + }); + it("keeps the System controls header row override active on mobile", () => { const css = readFileSync(join(process.cwd(), "app/components/command-center/areas/SystemControlsArea.css"), "utf8"); diff --git a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx index 7c05283d40..ef714402ae 100644 --- a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx @@ -31,6 +31,7 @@ import { } from "../../../api/legacy"; import { subscribeSse } from "../../../sse-bus"; import type { ToastType } from "../../../hooks/useToast"; +import { copyTextToClipboard } from "../../../utils/copyToClipboard"; import "./SystemControlsArea.css"; /* @@ -356,8 +357,16 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr () => runAction("diagnostics", async () => { const diagnostics = await buildDiagnostics(); - await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2)); - toast(t("systemControls.diagnosticsCopied", "Diagnostics copied to clipboard"), "success"); + /* + FNXC:SystemPanel 2026-07-12-00:00: + Diagnostics copy must use copyTextToClipboard because navigator.clipboard is undefined on non-secure origins such as mobile http://fusionstudio:4040. Calling writeText directly previously crashed with reading 'writeText' instead of surfacing a clear copy failure. + */ + const copied = await copyTextToClipboard(JSON.stringify(diagnostics, null, 2)); + if (copied) { + toast(t("systemControls.diagnosticsCopied", "Diagnostics copied to clipboard"), "success"); + return; + } + toast(t("systemControls.diagnosticsCopyFailed", "Could not copy diagnostics to clipboard"), "error"); }), [buildDiagnostics, runAction, t, toast], );