From b48678bf76b005d7a87531f5ef2197292e7c7f89 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 26 May 2026 23:47:13 +0200 Subject: [PATCH] Add terminal log export actions --- frontend/app/terminal/page.test.tsx | 63 ++++++++++++++++++++++ frontend/app/terminal/page.tsx | 84 ++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 frontend/app/terminal/page.test.tsx diff --git a/frontend/app/terminal/page.test.tsx b/frontend/app/terminal/page.test.tsx new file mode 100644 index 00000000..de5ce81e --- /dev/null +++ b/frontend/app/terminal/page.test.tsx @@ -0,0 +1,63 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import TerminalPage, { serializeLogs } from "./page"; + +const eventSources: MockEventSource[] = []; + +class MockEventSource { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + close = vi.fn(); + + constructor(public readonly url: string) { + eventSources.push(this); + } + + emit(log: string) { + this.onmessage?.({ data: JSON.stringify(log) } as MessageEvent); + } +} + +describe("TerminalPage", () => { + beforeEach(() => { + eventSources.length = 0; + vi.stubGlobal("EventSource", MockEventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("serializes logs as newline-delimited text", () => { + expect(serializeLogs(["first log", "second log"])).toBe("first log\nsecond log"); + }); + + it("disables export actions until logs are available", () => { + render(); + + expect(screen.getByRole("button", { name: /download \.log/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /^copy$/i })).toBeDisabled(); + }); + + it("copies the current terminal logs to the clipboard", async () => { + const user = userEvent.setup(); + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + + render(); + await user.click(screen.getByRole("button", { name: /start new analysis/i })); + + act(() => { + eventSources[0].emit("scanning contract"); + eventSources[0].emit("analysis complete"); + }); + + const copyButton = screen.getByRole("button", { name: /^copy$/i }); + await waitFor(() => expect(copyButton).toBeEnabled()); + await user.click(copyButton); + + expect(writeText).toHaveBeenCalledWith("scanning contract\nanalysis complete"); + expect(screen.getByRole("status")).toHaveTextContent("Logs copied to clipboard."); + }); +}); diff --git a/frontend/app/terminal/page.tsx b/frontend/app/terminal/page.tsx index 19499c2a..3de6235e 100644 --- a/frontend/app/terminal/page.tsx +++ b/frontend/app/terminal/page.tsx @@ -1,12 +1,31 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; +import { Copy, Download } from "lucide-react"; import { AnalysisTerminal } from "../components/AnalysisTerminal"; +export const serializeLogs = (logs: string[]) => logs.join("\n"); + export default function TerminalPage() { const [logs, setLogs] = useState([]); const [isAnalyzing, setIsAnalyzing] = useState(false); const [connectionError, setConnectionError] = useState(null); + const [toastMessage, setToastMessage] = useState(null); + const hasLogs = logs.length > 0; + + useEffect(() => { + if (!toastMessage) return; + + const timeoutId = window.setTimeout(() => { + setToastMessage(null); + }, 2500); + + return () => window.clearTimeout(timeoutId); + }, [toastMessage]); + + const showToast = useCallback((message: string) => { + setToastMessage(message); + }, []); const startAnalysis = useCallback(() => { setLogs([]); @@ -42,6 +61,39 @@ export default function TerminalPage() { startAnalysis(); }, [startAnalysis]); + const handleDownloadLogs = useCallback(() => { + if (!hasLogs) { + showToast("No logs to download yet."); + return; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const blob = new Blob([serializeLogs(logs)], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = `sanctifier-analysis-${timestamp}.log`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, [hasLogs, logs, showToast]); + + const handleCopyLogs = useCallback(async () => { + if (!hasLogs) { + showToast("No logs to copy yet."); + return; + } + + try { + await navigator.clipboard.writeText(serializeLogs(logs)); + showToast("Logs copied to clipboard."); + } catch { + showToast("Could not copy logs to clipboard."); + } + }, [hasLogs, logs, showToast]); + return (
@@ -91,6 +143,16 @@ export default function TerminalPage() {
)} + {toastMessage && ( +
+ {toastMessage} +
+ )} +
@@ -117,6 +179,26 @@ export default function TerminalPage() {

Export Logs

Keep a record of your analysis sessions for compliance and auditing purposes.

+
+ + +