FN-7883: expand Command Center bug report with full diagnostics bundle
Replace the bug-report flow's last-5-error-lines excerpt with the full buildDiagnostics() bundle (health, runtime/system info, recent logs) behind a single confirmation prompt. - doReportBug now reuses buildDiagnostics() instead of independently fetching a 5-line error excerpt - Single confirm() prompt covers the whole diagnostics bundle instead of only recent errors - Diagnostics are embedded as a collapsible <details> JSON code block under a new ### Diagnostics section - Preserved existing safeguards: fenceSafe() neutralization of embedded code fences and BUG_URL_BODY_CAP truncation - Added tests covering: full bundle inclusion on confirm, omission on decline, fence-breakout neutralization, and oversized-bundle truncation Files changed: .../__tests__/SystemControlsArea.test.tsx | 96 ++++++++++++++++++++++ .../command-center/areas/SystemControlsArea.tsx | 53 +++++++----- 2 files changed, 128 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-7883 Fusion-Task-Lineage: 44dd26de-d0f6-427b-a082-59d580f31674 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -243,6 +243,102 @@ describe("SystemControlsArea layout integration", () => {
|
||||
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("writeText"), "error");
|
||||
});
|
||||
|
||||
it("embeds the full diagnostics bundle in the bug report body when the operator confirms", async () => {
|
||||
mockFetchSystemLogs.mockResolvedValue({
|
||||
entries: [{ timestamp: "2026-07-12T00:00:00.000Z", level: "error", message: "boom" }],
|
||||
});
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
await renderSystemTab();
|
||||
|
||||
const reportCard = await screen.findByTestId("cc-syscontrol-report-bug");
|
||||
fireEvent.click(within(reportCard).getByRole("button", { name: "Report" }));
|
||||
|
||||
await waitFor(() => expect(openSpy).toHaveBeenCalledTimes(1));
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
||||
const url = openSpy.mock.calls[0]?.[0] as string;
|
||||
const body = decodeURIComponent(url.split("?body=")[1] ?? "");
|
||||
expect(url).toContain("github.com");
|
||||
expect(url).toContain("/issues/new");
|
||||
expect(body).toContain("### What happened");
|
||||
expect(body).toContain("### Environment");
|
||||
expect(body).toContain("### Diagnostics");
|
||||
expect(body).toContain("<details><summary>Diagnostics</summary>");
|
||||
expect(body).toContain('"recentLogs"');
|
||||
expect(body).toContain("boom");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("omits the diagnostics block from the bug report body when the operator declines", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
await renderSystemTab();
|
||||
|
||||
const reportCard = await screen.findByTestId("cc-syscontrol-report-bug");
|
||||
fireEvent.click(within(reportCard).getByRole("button", { name: "Report" }));
|
||||
|
||||
await waitFor(() => expect(openSpy).toHaveBeenCalledTimes(1));
|
||||
const url = openSpy.mock.calls[0]?.[0] as string;
|
||||
const body = decodeURIComponent(url.split("?body=")[1] ?? "");
|
||||
expect(body).toContain("### What happened");
|
||||
expect(body).toContain("### Environment");
|
||||
expect(body).not.toContain("### Diagnostics");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("neutralizes embedded code fences in log messages so they cannot break out of the diagnostics fence", async () => {
|
||||
mockFetchSystemLogs.mockResolvedValue({
|
||||
entries: [{ timestamp: "2026-07-12T00:00:00.000Z", level: "error", message: "```oops``` breakout" }],
|
||||
});
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
await renderSystemTab();
|
||||
|
||||
const reportCard = await screen.findByTestId("cc-syscontrol-report-bug");
|
||||
fireEvent.click(within(reportCard).getByRole("button", { name: "Report" }));
|
||||
|
||||
await waitFor(() => expect(openSpy).toHaveBeenCalledTimes(1));
|
||||
const url = openSpy.mock.calls[0]?.[0] as string;
|
||||
const body = decodeURIComponent(url.split("?body=")[1] ?? "");
|
||||
// The fenced JSON block should contain no raw ``` other than the fence delimiters themselves.
|
||||
const jsonBlockMatch = body.match(/```json\n([\s\S]*?)\n```/);
|
||||
expect(jsonBlockMatch).not.toBeNull();
|
||||
expect(jsonBlockMatch?.[1]).not.toContain("```");
|
||||
expect(jsonBlockMatch?.[1]).toContain("'''oops''' breakout");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("truncates an oversized diagnostics bundle in the bug report body with the truncation cap marker", async () => {
|
||||
mockFetchSystemLogs.mockResolvedValue({
|
||||
entries: Array.from({ length: 100 }, (_, i) => ({
|
||||
timestamp: "2026-07-12T00:00:00.000Z",
|
||||
level: "error" as const,
|
||||
message: `error-line-${i}-${"x".repeat(100)}`,
|
||||
})),
|
||||
});
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
await renderSystemTab();
|
||||
|
||||
const reportCard = await screen.findByTestId("cc-syscontrol-report-bug");
|
||||
fireEvent.click(within(reportCard).getByRole("button", { name: "Report" }));
|
||||
|
||||
await waitFor(() => expect(openSpy).toHaveBeenCalledTimes(1));
|
||||
const url = openSpy.mock.calls[0]?.[0] as string;
|
||||
const body = decodeURIComponent(url.split("?body=")[1] ?? "");
|
||||
expect(body).toContain("\u2026(truncated)");
|
||||
expect(body.length).toBeLessThanOrEqual(5500 + "\n\u2026(truncated)".length);
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
|
||||
@@ -371,29 +371,31 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
[buildDiagnostics, runAction, t, toast],
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:SystemPanel 2026-07-12-15:30:
|
||||
Requirement change (FN-7883): the bug-report flow previously offered only the
|
||||
last 5 error log lines behind confirmation. That gave maintainers too little
|
||||
context for a first triage pass. Now doReportBug reuses the exact same
|
||||
buildDiagnostics() bundle that "Copy diagnostics" produces (health,
|
||||
runtime/system info, recent logs) and asks a single confirmation question
|
||||
covering that whole bundle. The confirm gate, fenceSafe neutralization, and
|
||||
BUG_URL_BODY_CAP truncation are preserved unchanged because this content is
|
||||
still sent to a public github.com issue and must never be included without
|
||||
explicit operator consent, must not let a log line break out of the fenced
|
||||
code block, and must not produce an over-length URL.
|
||||
*/
|
||||
const doReportBug = useCallback(
|
||||
() =>
|
||||
runAction("report-bug", async () => {
|
||||
const health = await fetchDashboardHealth().catch(() => null);
|
||||
const recentErrors = info?.logsSupported
|
||||
? await fetchSystemLogs(200)
|
||||
.then((r) => r.entries.filter((entry) => entry.level === "error").slice(-5))
|
||||
.catch(() => [])
|
||||
: [];
|
||||
// FNXC:SystemPanel 2026-07-12-14:05: The recent-errors excerpt is
|
||||
// server log content sent to github.com. Require explicit confirmation
|
||||
// before including it (operator may not want internal logs public), and
|
||||
// neutralize embedded ``` so a log line can't break out of the fence.
|
||||
const includeErrors =
|
||||
recentErrors.length > 0 &&
|
||||
window.confirm(
|
||||
t(
|
||||
"systemControls.reportBugConfirm",
|
||||
"Include the last {{count}} server error log line(s) in the GitHub issue? They will be sent to github.com — review after the issue opens.",
|
||||
{ count: recentErrors.length },
|
||||
),
|
||||
);
|
||||
const fenceSafe = (text: string) => text.replace(/`/g, "'");
|
||||
const includeDiagnostics = window.confirm(
|
||||
t(
|
||||
"systemControls.reportBugConfirm",
|
||||
"Include diagnostic info and recent logs (health, runtime info, recent server logs) in the GitHub issue? They will be sent to github.com — review after the issue opens.",
|
||||
),
|
||||
);
|
||||
const diagnostics = includeDiagnostics ? await buildDiagnostics() : null;
|
||||
let body = [
|
||||
"### What happened",
|
||||
"",
|
||||
@@ -404,14 +406,23 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
`- Platform: ${info?.platform ?? "unknown"} (${info?.arch ?? "?"}), Node ${info?.nodeVersion ?? "?"}`,
|
||||
`- Uptime: ${info?.uptimeSeconds ?? "?"}s, supervised: ${info?.supervised ?? false}`,
|
||||
"",
|
||||
...(includeErrors
|
||||
? ["### Recent server errors", "```", ...recentErrors.map((entry) => fenceSafe(`${entry.prefix ? `[${entry.prefix}] ` : ""}${entry.message}`)), "```"]
|
||||
...(diagnostics
|
||||
? [
|
||||
"### Diagnostics",
|
||||
"<details><summary>Diagnostics</summary>",
|
||||
"",
|
||||
"```json",
|
||||
fenceSafe(JSON.stringify(diagnostics, null, 2)),
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
]
|
||||
: []),
|
||||
].join("\n");
|
||||
if (body.length > BUG_URL_BODY_CAP) body = `${body.slice(0, BUG_URL_BODY_CAP)}\n…(truncated)`;
|
||||
window.open(`${GITHUB_NEW_ISSUE_URL}?body=${encodeURIComponent(body)}`, "_blank", "noopener");
|
||||
}),
|
||||
[info, runAction],
|
||||
[buildDiagnostics, info, runAction, t],
|
||||
);
|
||||
|
||||
// ── Control definitions ───────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user