FN-7882: fix Copy diagnostics crash on non-secure origins

Route Command Center diagnostics copy through the shared clipboard helper so it no longer throws on non-secure origins (e.g. mobile http://fusionstudio:4040).

- Replace direct navigator.clipboard.writeText call in SystemControlsArea's diagnostics copy handler with copyTextToClipboard, which guards for secure-context clipboard support and falls back to document.execCommand("copy").
- Surface a distinct failure toast ("Could not copy diagnostics to clipboard") when neither clipboard path succeeds, instead of crashing.
- Add regression tests covering the execCommand fallback, the secure-context Clipboard API path, and the failure-toast path when both copy mechanisms are unavailable.
- Add a patch changeset documenting the fix.

Files changed:
 .changeset/fn-7882-copy-diagnostics-fix.md         |  7 ++
 .../__tests__/SystemControlsArea.test.tsx          | 87 +++++++++++++++++++++-
 .../command-center/areas/SystemControlsArea.tsx    | 13 +++-
 3 files changed, 102 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7882
Fusion-Task-Lineage: eceb93bf-b32d-4127-b370-4779cb4e4e27
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 17:32:41 -07:00
parent b84bd11256
commit e92a342b1e
3 changed files with 102 additions and 5 deletions

View File

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

View File

@@ -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(<CommandCenter projectId="proj-1" addToast={addToast} />);
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(<CommandCenter projectId="proj-1" />);
@@ -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");

View File

@@ -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],
);