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:
7
.changeset/fn-7882-copy-diagnostics-fix.md
Normal file
7
.changeset/fn-7882-copy-diagnostics-fix.md
Normal 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.
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user