From 790262bb3c375fb015fb7e18f7b162e090426e9b Mon Sep 17 00:00:00 2001 From: Udaybir Singh Date: Thu, 13 Aug 2026 16:32:09 +0530 Subject: [PATCH 1/2] test: cover session and streaming hooks --- .../src/hooks/__tests__/useSession.test.jsx | 127 +++++++++++++++++ .../__tests__/useStreamingOutput.test.jsx | 134 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 webapp/src/hooks/__tests__/useSession.test.jsx create mode 100644 webapp/src/hooks/__tests__/useStreamingOutput.test.jsx diff --git a/webapp/src/hooks/__tests__/useSession.test.jsx b/webapp/src/hooks/__tests__/useSession.test.jsx new file mode 100644 index 0000000..dfd6648 --- /dev/null +++ b/webapp/src/hooks/__tests__/useSession.test.jsx @@ -0,0 +1,127 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useSession } from "../useSession"; +import { vi, beforeEach, test, expect } from "vitest"; + +const mockPost = vi.fn(); +vi.mock("axios", () => ({ default: { post: (...args) => mockPost(...args) } })); + +const mockSetSessionIdForApi = vi.fn(); +vi.mock("../../api", () => ({ setSessionIdForApi: (...args) => mockSetSessionIdForApi(...args) })); + +const SESSION = { data: { session_id: "sid-1", ws_token: "tok-1" } }; + +beforeEach(() => { + vi.clearAllMocks(); + mockPost.mockReset(); + mockSetSessionIdForApi.mockReset(); +}); + +test("creates a session on mount and exposes it", async () => { + mockPost.mockResolvedValue(SESSION); + + const { result } = renderHook(() => useSession()); + + await waitFor(() => expect(result.current.sessionId).toBe("sid-1")); + expect(result.current.wsToken).toBe("tok-1"); + expect(result.current.sessionLoading).toBe(false); + expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("/create_session")); + expect(mockSetSessionIdForApi).toHaveBeenCalledWith("sid-1"); +}); + +test("sets capacity message on 503", async () => { + mockPost.mockRejectedValue({ response: { status: 503 } }); + + const { result } = renderHook(() => useSession()); + + await waitFor(() => expect(result.current.sessionError).toMatch(/Server capacity/)); + expect(result.current.sessionId).toBeNull(); + expect(mockSetSessionIdForApi).toHaveBeenCalledWith(null); +}); + +test("sets rate-limit message on 429", async () => { + mockPost.mockRejectedValue({ response: { status: 429 } }); + + const { result } = renderHook(() => useSession()); + + await waitFor(() => expect(result.current.sessionError).toMatch(/Rate limited/)); +}); + +test("falls back to the error message for other failures", async () => { + mockPost.mockRejectedValue(new Error("Network down")); + + const { result } = renderHook(() => useSession()); + + await waitFor(() => expect(result.current.sessionError).toBe("Network down")); +}); + +test("ends the session and clears state when it is current", async () => { + mockPost.mockResolvedValue(SESSION); + const { result } = renderHook(() => useSession()); + await waitFor(() => expect(result.current.sessionId).toBe("sid-1")); + mockPost.mockClear(); + + await act(async () => { + await result.current.endSession("sid-1"); + }); + + expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("/end_session"), { session_id: "sid-1" }); + expect(result.current.sessionId).toBeNull(); + expect(mockSetSessionIdForApi).toHaveBeenCalledWith(null); +}); + +test("does not call the backend when ending a null session", async () => { + mockPost.mockResolvedValue(SESSION); + const { result } = renderHook(() => useSession()); + await waitFor(() => expect(result.current.sessionId).toBe("sid-1")); + mockPost.mockClear(); + + await act(async () => { + await result.current.endSession(null); + }); + + expect(mockPost).not.toHaveBeenCalled(); +}); + +test("clears session state for a 404 session error", async () => { + mockPost.mockResolvedValue(SESSION); + const { result } = renderHook(() => useSession()); + await waitFor(() => expect(result.current.sessionId).toBe("sid-1")); + + let handled; + act(() => { + handled = result.current.handleSessionError({ response: { status: 404 } }); + }); + + expect(handled).toBe(true); + expect(result.current.sessionError).toMatch(/Session expired/); + expect(result.current.sessionId).toBeNull(); +}); + +test("returns false for non-404 errors", async () => { + mockPost.mockResolvedValue(SESSION); + const { result } = renderHook(() => useSession()); + await waitFor(() => expect(result.current.sessionId).toBe("sid-1")); + + expect(result.current.handleSessionError({ response: { status: 500 } })).toBe(false); +}); + +test("clearSessionError resets the error", async () => { + mockPost.mockRejectedValue({ response: { status: 503 } }); + const { result } = renderHook(() => useSession()); + await waitFor(() => expect(result.current.sessionError).toBeTruthy()); + + act(() => result.current.clearSessionError()); + + expect(result.current.sessionError).toBeNull(); +}); + +test("ends the session on unmount if one is active", async () => { + mockPost.mockResolvedValue(SESSION); + const { result, unmount } = renderHook(() => useSession()); + await waitFor(() => expect(result.current.sessionId).toBe("sid-1")); + mockPost.mockClear(); + + unmount(); + + await waitFor(() => expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("/end_session"), { session_id: "sid-1" })); +}); diff --git a/webapp/src/hooks/__tests__/useStreamingOutput.test.jsx b/webapp/src/hooks/__tests__/useStreamingOutput.test.jsx new file mode 100644 index 0000000..c30ddf7 --- /dev/null +++ b/webapp/src/hooks/__tests__/useStreamingOutput.test.jsx @@ -0,0 +1,134 @@ +import { renderHook, act } from "@testing-library/react"; +import { useStreamingOutput } from "../useStreamingOutput"; +import { vi, beforeEach, test, expect } from "vitest"; + +const mockOn = vi.fn(); +const mockDisconnect = vi.fn(); +const mockIo = vi.fn(() => ({ + on: mockOn, + disconnect: mockDisconnect, +})); +vi.mock("socket.io-client", () => ({ io: (...args) => mockIo(...args) })); + +let handlers; +beforeEach(() => { + vi.clearAllMocks(); + handlers = {}; + mockOn.mockImplementation((event, cb) => { + handlers[event] = cb; + }); +}); + +const emit = (event, payload) => { + act(() => handlers[event](payload)); +}; + +test("connects to the debug namespace with session credentials", () => { + renderHook(() => useStreamingOutput("s1", "t1")); + + expect(mockIo).toHaveBeenCalledWith( + expect.stringContaining("/ws/debug"), + expect.objectContaining({ query: { session_id: "s1", ws_token: "t1" } }) + ); +}); + +test("skips connecting when session or token is missing", () => { + renderHook(() => useStreamingOutput(null, "t1")); + renderHook(() => useStreamingOutput("s1", null)); + + expect(mockIo).not.toHaveBeenCalled(); +}); + +test("marks the stream connected on the connect event", () => { + const { result } = renderHook(() => useStreamingOutput("s1", "t1")); + + emit("connect"); + + expect(result.current.isConnected).toBe(true); +}); + +test("appends string gdb_output lines", () => { + const { result } = renderHook(() => useStreamingOutput("s1", "t1")); + + emit("gdb_output", { payload: "Breakpoint 1 hit" }); + emit("gdb_output", { payload: "next line" }); + + expect(result.current.lines).toEqual(["Breakpoint 1 hit", "next line"]); +}); + +test("stringifies object payloads", () => { + const { result } = renderHook(() => useStreamingOutput("s1", "t1")); + + emit("gdb_output", { payload: { token: 42 } }); + + expect(result.current.lines).toEqual([JSON.stringify({ token: 42 })]); +}); + +test("ignores empty or null payloads", () => { + const { result } = renderHook(() => useStreamingOutput("s1", "t1")); + + emit("gdb_output", { payload: null }); + emit("gdb_output", { payload: " " }); + + expect(result.current.lines).toEqual([]); +}); + +test("caps the line buffer at 1000 entries", () => { + const { result } = renderHook(() => useStreamingOutput("s1", "t1")); + + act(() => { + for (let i = 0; i < 1005; i++) { + handlers.gdb_output({ payload: `line-${i}` }); + } + }); + + expect(result.current.lines).toHaveLength(1000); + expect(result.current.lines[0]).toBe("line-5"); + expect(result.current.lines[999]).toBe("line-1004"); +}); + +test("reports session expiry", () => { + const { result } = renderHook(() => useStreamingOutput("s1", "t1")); + + emit("session_expired"); + + expect(result.current.isConnected).toBe(false); + expect(result.current.error).toMatch(/Session expired/); +}); + +test("reports connect errors", () => { + const { result } = renderHook(() => useStreamingOutput("s1", "t1")); + + emit("connect_error"); + + expect(result.current.isConnected).toBe(false); + expect(result.current.error).toMatch(/Failed to connect/); +}); + +test("marks the stream disconnected on the disconnect event", () => { + const { result } = renderHook(() => useStreamingOutput("s1", "t1")); + + emit("connect"); + emit("disconnect"); + + expect(result.current.isConnected).toBe(false); +}); + +test("clearOutput empties lines and error", () => { + const { result } = renderHook(() => useStreamingOutput("s1", "t1")); + emit("gdb_output", { payload: "some output" }); + emit("session_expired"); + + act(() => result.current.clearOutput()); + + expect(result.current.lines).toEqual([]); + expect(result.current.error).toBeNull(); +}); + +test("disconnects the socket on unmount", () => { + const { unmount } = renderHook(() => useStreamingOutput("s1", "t1")); + + unmount(); + + expect(mockDisconnect).toHaveBeenCalled(); +}); From c59e7d9129d8ff1ab1669b9e15bc8eabe80c3a13 Mon Sep 17 00:00:00 2001 From: Udaybir Singh Date: Thu, 13 Aug 2026 16:41:52 +0530 Subject: [PATCH 2/2] test: lift frontend coverage to 80%+ and enforce threshold --- webapp/src/api.test.jsx | 98 ++++++++ .../Breakpoint/__tests__/Breakpoint.test.jsx | 124 ++++++++-- .../Functions/__tests__/Functions.test.jsx | 102 ++++++++- .../__tests__/BreakPoints.test.jsx | 99 ++++++++ .../MemoryMap/__tests__/MemoryMap.test.jsx | 105 +++++++++ .../Threads/__tests__/Threads.test.jsx | 77 +++++++ .../components/Stack/__tests__/Stack.test.jsx | 95 +++++++- webapp/src/pages/Demo/__tests__/Demo.test.jsx | 214 ++++++++++++++++++ .../src/pages/Login/__tests__/Login.test.jsx | 37 +++ webapp/src/setupTests.js | 4 + webapp/vite.config.js | 9 + 11 files changed, 918 insertions(+), 46 deletions(-) create mode 100644 webapp/src/api.test.jsx create mode 100644 webapp/src/components/GdbComponents/BreakPoints/__tests__/BreakPoints.test.jsx create mode 100644 webapp/src/components/GdbComponents/MemoryMap/__tests__/MemoryMap.test.jsx create mode 100644 webapp/src/components/GdbComponents/Threads/__tests__/Threads.test.jsx create mode 100644 webapp/src/pages/Demo/__tests__/Demo.test.jsx create mode 100644 webapp/src/pages/Login/__tests__/Login.test.jsx diff --git a/webapp/src/api.test.jsx b/webapp/src/api.test.jsx new file mode 100644 index 0000000..0ad4426 --- /dev/null +++ b/webapp/src/api.test.jsx @@ -0,0 +1,98 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import api, { makeRequest, setSessionIdForApi, onSessionExpired } from "./api"; + +// A custom adapter captures the outgoing config without hitting the network. +// The response interceptor still runs on rejected responses. +const captureAdapter = () => { + const captured = {}; + const adapter = (config) => { + captured.config = config; + return Promise.reject(new Error("no network")); + }; + return { captured, adapter }; +}; + +// Axios stringifies JSON bodies before the adapter runs. +const body = (config) => JSON.parse(config.data); + +describe("api request interceptor", () => { + beforeEach(() => { + setSessionIdForApi(null); + }); + afterEach(() => { + delete api.defaults.adapter; + }); + + it("makeRequest adds session_id when sessionId is provided", async () => { + const { captured, adapter } = captureAdapter(); + api.defaults.adapter = adapter; + await makeRequest("/x", { a: 1 }, "sess-1").catch(() => {}); + expect(captured.config.url).toBe("/x"); + expect(body(captured.config)).toEqual({ a: 1, session_id: "sess-1" }); + }); + + it("makeRequest does not add session_id when omitted", async () => { + const { captured, adapter } = captureAdapter(); + api.defaults.adapter = adapter; + await makeRequest("/x", { a: 1 }).catch(() => {}); + expect(body(captured.config)).toEqual({ a: 1 }); + }); + + it("injects active session id into post bodies", async () => { + const { captured, adapter } = captureAdapter(); + api.defaults.adapter = adapter; + setSessionIdForApi("active-id"); + await api.post("/y", { b: 2 }).catch(() => {}); + expect(body(captured.config)).toEqual({ b: 2, session_id: "active-id" }); + }); + + it("does not override an existing session_id", async () => { + const { captured, adapter } = captureAdapter(); + api.defaults.adapter = adapter; + setSessionIdForApi("active-id"); + await api.post("/y", { session_id: "already-set" }).catch(() => {}); + expect(body(captured.config).session_id).toBe("already-set"); + }); + + it("leaves non-POST requests untouched", async () => { + const { captured, adapter } = captureAdapter(); + api.defaults.adapter = adapter; + setSessionIdForApi("active-id"); + await api.get("/y").catch(() => {}); + expect(captured.config.data).toBeUndefined(); + }); + + it("creates a data object when a POST body is missing", async () => { + const { captured, adapter } = captureAdapter(); + api.defaults.adapter = adapter; + setSessionIdForApi("active-id"); + await api.post("/y").catch(() => {}); + expect(body(captured.config)).toEqual({ session_id: "active-id" }); + }); +}); + +describe("api response interceptor", () => { + it("triggers session expiry callback on 404", async () => { + const cb = vi.fn(); + onSessionExpired(cb); + const adapter404 = () => Promise.reject({ response: { status: 404 } }); + const err = await api + .post("/gone", {}, { adapter: adapter404 }) + .catch((e) => e); + expect(err.response.status).toBe(404); + expect(cb).toHaveBeenCalledTimes(1); + onSessionExpired(null); + }); + + it("does not trigger callback on other statuses", async () => { + const cb = vi.fn(); + onSessionExpired(cb); + const adapter500 = () => Promise.reject({ response: { status: 500 } }); + const err = await api + .post("/boom", {}, { adapter: adapter500 }) + .catch((e) => e); + expect(err.response.status).toBe(500); + expect(cb).not.toHaveBeenCalled(); + onSessionExpired(null); + }); +}); diff --git a/webapp/src/components/Breakpoint/__tests__/Breakpoint.test.jsx b/webapp/src/components/Breakpoint/__tests__/Breakpoint.test.jsx index 26747ad..d1a3968 100644 --- a/webapp/src/components/Breakpoint/__tests__/Breakpoint.test.jsx +++ b/webapp/src/components/Breakpoint/__tests__/Breakpoint.test.jsx @@ -1,40 +1,116 @@ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import Breakpoint from "../Breakpoint.jsx"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { vi } from "vitest"; -vi.mock("../../../context/DataContext.jsx", () => ({ - DataState: () => ({ - sessionId: 'test-session-123', +const state = vi.hoisted(() => ({ + value: { + sessionId: "test-session-123", sessionLoading: false, sessionError: null, createSession: vi.fn(), clearSessionError: vi.fn(), - }), + }, })); -// Breakpoint -test("renders Breakpoint component with basic structure", () => { - render(); +vi.mock("../../../context/DataContext.jsx", () => ({ + DataState: () => state.value, +})); +vi.mock("react-toastify", () => ({ + ToastContainer: () => null, + toast: { error: vi.fn(), success: vi.fn() }, +})); +vi.mock("../../../api", () => ({ + makeRequest: vi.fn(), +})); - const addBreakpointElement = screen.getByText(/Add Breakpoint/i); - expect(addBreakpointElement).toBeInTheDocument(); +import { toast } from "react-toastify"; +import { makeRequest } from "../../../api"; +import Breakpoint from "../Breakpoint.jsx"; - const lineInputs = screen.getAllByRole("textbox"); - expect(lineInputs).toHaveLength(2); -}); +describe("Breakpoint", () => { + beforeEach(() => { + state.value = { + sessionId: "test-session-123", + sessionLoading: false, + sessionError: null, + createSession: vi.fn(), + clearSessionError: vi.fn(), + }; + makeRequest.mockReset(); + toast.error.mockClear(); + toast.success.mockClear(); + }); -test("renders Breakpoint component and checks about text Line", async () => { - render(); - const lineLabel = screen.getByText(/Line/i); - expect(lineLabel).toBeInTheDocument(); -}); + test("renders the add breakpoint form", () => { + render(); + expect(screen.getByText("Add Breakpoint")).toBeInTheDocument(); + expect(screen.getAllByRole("textbox")).toHaveLength(2); + }); + + test("shows loading state while initializing", () => { + state.value.sessionLoading = true; + render(); + expect(screen.getByText("Initializing debug session...")).toBeInTheDocument(); + }); + + test("shows error banner and starts a new session", () => { + state.value.sessionError = "Session failed"; + render(); + expect(screen.getByText("Session failed")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start New Session")); + expect(state.value.clearSessionError).toHaveBeenCalled(); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("shows no-session state and starts a debug session", () => { + state.value.sessionId = null; + render(); + expect(screen.getByText("No active session.")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start Debug Session")); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("toasts an error when adding a breakpoint with no fields", () => { + render(); + fireEvent.click(screen.getByText("Add")); + expect(toast.error).toHaveBeenCalledWith("Enter any of the field", { + autoClose: 1000, + }); + expect(makeRequest).not.toHaveBeenCalled(); + }); + + test("saves a breakpoint by line and toasts success", async () => { + makeRequest.mockResolvedValue({ data: { success: true } }); + render(); + + const inputs = screen.getAllByRole("textbox"); + fireEvent.change(inputs[0], { target: { value: "10" } }); + fireEvent.click(screen.getByText("Add")); + + expect(makeRequest).toHaveBeenCalledWith( + "/set_breakpoint", + { location: "10", name: "program" }, + "test-session-123" + ); + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith("Added breakpoint", { + autoClose: 1000, + }) + ); + }); -test("typing in Line input updates the value correctly", () => { - render(); + test("toasts an error when the breakpoint request fails", async () => { + makeRequest.mockRejectedValue(new Error("boom")); + render(); - const lineInput = screen.getAllByRole("textbox"); - fireEvent.change(lineInput[0], { target: { value: "123" } }); + const inputs = screen.getAllByRole("textbox"); + fireEvent.change(inputs[1], { target: { value: "main" } }); + fireEvent.click(screen.getByText("Add")); - expect(lineInput[0]).toHaveValue("123"); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Something went Wrong", { + autoClose: 1000, + }) + ); + }); }); diff --git a/webapp/src/components/Functions/__tests__/Functions.test.jsx b/webapp/src/components/Functions/__tests__/Functions.test.jsx index e96e6ab..1566b60 100644 --- a/webapp/src/components/Functions/__tests__/Functions.test.jsx +++ b/webapp/src/components/Functions/__tests__/Functions.test.jsx @@ -1,27 +1,105 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; -import Functions from "../Functions.jsx"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { vi } from "vitest"; -vi.mock("../../../context/DataContext.jsx", () => ({ - DataState: () => ({ +const state = vi.hoisted(() => ({ + value: { refresh: false, functions: [], setFunctions: vi.fn(), - sessionId: 'test-session-123', + sessionId: "test-session-123", sessionLoading: false, sessionError: null, createSession: vi.fn(), clearSessionError: vi.fn(), - }), + }, +})); + +vi.mock("../../../context/DataContext.jsx", () => ({ + DataState: () => state.value, +})); +vi.mock("../../../api", () => ({ + makeRequest: vi.fn(), })); -test("renders Functions component with correct heading", () => { - render(); +import { makeRequest } from "../../../api"; +import Functions from "../Functions.jsx"; + +describe("Functions", () => { + beforeEach(() => { + state.value = { + refresh: false, + functions: [], + setFunctions: vi.fn(), + sessionId: "test-session-123", + sessionLoading: false, + sessionError: null, + createSession: vi.fn(), + clearSessionError: vi.fn(), + }; + makeRequest.mockReset(); + }); + + test("renders Functions heading with active session", () => { + render(); + expect(screen.getByText(/Functions/i)).toBeInTheDocument(); + }); + + test("shows loading state while initializing", () => { + state.value.sessionLoading = true; + render(); + expect(screen.getByText("Initializing debug session...")).toBeInTheDocument(); + }); + + test("shows error banner and starts a new session", () => { + state.value.sessionError = "Session failed"; + render(); + expect(screen.getByText("Session failed")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start New Session")); + expect(state.value.clearSessionError).toHaveBeenCalled(); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("shows no-session state and starts a debug session", () => { + state.value.sessionId = null; + render(); + expect(screen.getByText("No active session.")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start Debug Session")); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("fetches functions when refresh fires with a session", async () => { + state.value.refresh = true; + state.value.setFunctions = vi.fn((v) => { + state.value.functions = v; + }); + makeRequest.mockResolvedValue({ data: { result: "fn-data" } }); + + render(); + + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + "/get_locals", + { name: "program" }, + "test-session-123" + ) + ); + await waitFor(() => + expect(state.value.setFunctions).toHaveBeenCalledWith("fn-data") + ); + }); - const headingElement = screen.getByText(/Functions/i); - expect(headingElement).toBeInTheDocument(); + test("does not fetch when there is no session", async () => { + state.value.refresh = true; + state.value.sessionId = null; + render(); + await waitFor(() => expect(makeRequest).not.toHaveBeenCalled()); + }); - const functionElements = screen.queryAllByRole("link"); - expect(functionElements).toHaveLength(0); + test("swallows fetch errors", async () => { + state.value.refresh = true; + makeRequest.mockRejectedValue(new Error("boom")); + render(); + await waitFor(() => expect(makeRequest).toHaveBeenCalled()); + }); }); diff --git a/webapp/src/components/GdbComponents/BreakPoints/__tests__/BreakPoints.test.jsx b/webapp/src/components/GdbComponents/BreakPoints/__tests__/BreakPoints.test.jsx new file mode 100644 index 0000000..4e75755 --- /dev/null +++ b/webapp/src/components/GdbComponents/BreakPoints/__tests__/BreakPoints.test.jsx @@ -0,0 +1,99 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { vi } from "vitest"; + +const state = vi.hoisted(() => ({ + value: { + refresh: false, + infoBreakpointData: "", + setInfoBreakpointData: vi.fn(), + sessionId: "test-session-123", + sessionLoading: false, + sessionError: null, + createSession: vi.fn(), + clearSessionError: vi.fn(), + }, +})); + +vi.mock("../../../../context/DataContext.jsx", () => ({ + DataState: () => state.value, +})); +vi.mock("../../../../api", () => ({ + makeRequest: vi.fn(), +})); + +import { makeRequest } from "../../../../api"; +import BreakPoints from "../BreakPoints.jsx"; + +describe("BreakPoints", () => { + beforeEach(() => { + state.value = { + refresh: false, + infoBreakpointData: "", + setInfoBreakpointData: vi.fn(), + sessionId: "test-session-123", + sessionLoading: false, + sessionError: null, + createSession: vi.fn(), + clearSessionError: vi.fn(), + }; + makeRequest.mockReset(); + }); + + test("renders breakpoint data when present", () => { + state.value.infoBreakpointData = "1 breakpoint, keep y"; + render(); + expect(screen.getByText("1 breakpoint, keep y")).toBeInTheDocument(); + }); + + test("shows loading state while initializing", () => { + state.value.sessionLoading = true; + render(); + expect(screen.getByText("Initializing debug session...")).toBeInTheDocument(); + }); + + test("shows error banner and starts a new session", () => { + state.value.sessionError = "Session failed"; + render(); + expect(screen.getByText("Session failed")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start New Session")); + expect(state.value.clearSessionError).toHaveBeenCalled(); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("shows no-session state and starts a debug session", () => { + state.value.sessionId = null; + render(); + expect(screen.getByText("No active session.")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start Debug Session")); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("fetches breakpoints when refresh fires with a session", async () => { + state.value.refresh = true; + state.value.setInfoBreakpointData = vi.fn((v) => { + state.value.infoBreakpointData = v; + }); + makeRequest.mockResolvedValue({ data: { result: "bp-data" } }); + + render(); + + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + "/info_breakpoints", + { name: "program" }, + "test-session-123" + ) + ); + await waitFor(() => + expect(state.value.setInfoBreakpointData).toHaveBeenCalledWith("bp-data") + ); + }); + + test("swallows fetch errors", async () => { + state.value.refresh = true; + makeRequest.mockRejectedValue(new Error("boom")); + render(); + await waitFor(() => expect(makeRequest).toHaveBeenCalled()); + }); +}); diff --git a/webapp/src/components/GdbComponents/MemoryMap/__tests__/MemoryMap.test.jsx b/webapp/src/components/GdbComponents/MemoryMap/__tests__/MemoryMap.test.jsx new file mode 100644 index 0000000..8ea0ff8 --- /dev/null +++ b/webapp/src/components/GdbComponents/MemoryMap/__tests__/MemoryMap.test.jsx @@ -0,0 +1,105 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { vi } from "vitest"; + +const state = vi.hoisted(() => ({ + value: { + refresh: false, + memoryMap: "", + setMemoryMap: vi.fn(), + sessionId: "test-session-123", + sessionLoading: false, + sessionError: null, + createSession: vi.fn(), + clearSessionError: vi.fn(), + }, +})); + +vi.mock("../../../../context/DataContext.jsx", () => ({ + DataState: () => state.value, +})); +vi.mock("../../../../api", () => ({ + makeRequest: vi.fn(), +})); + +import { makeRequest } from "../../../../api"; +import MemoryMap from "../MemoryMap.jsx"; + +describe("MemoryMap", () => { + beforeEach(() => { + state.value = { + refresh: false, + memoryMap: "", + setMemoryMap: vi.fn(), + sessionId: "test-session-123", + sessionLoading: false, + sessionError: null, + createSession: vi.fn(), + clearSessionError: vi.fn(), + }; + makeRequest.mockReset(); + }); + + test("falls back to static sample data when memoryMap is empty", () => { + render(); + // The 8 sample rows share the same address prefix. + expect(screen.getAllByText(/0x7fffffffe270/)).toHaveLength(8); + }); + + test("renders the fetched memory map when present", () => { + state.value.memoryMap = "0x00400000: 0x00 0x01"; + render(); + expect(screen.getByText("0x00400000: 0x00 0x01")).toBeInTheDocument(); + }); + + test("shows loading state while initializing", () => { + state.value.sessionLoading = true; + render(); + expect(screen.getByText("Initializing debug session...")).toBeInTheDocument(); + }); + + test("shows error banner and starts a new session", () => { + state.value.sessionError = "Session failed"; + render(); + expect(screen.getByText("Session failed")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start New Session")); + expect(state.value.clearSessionError).toHaveBeenCalled(); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("shows no-session state and starts a debug session", () => { + state.value.sessionId = null; + render(); + expect(screen.getByText("No active session.")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start Debug Session")); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("fetches memory map when refresh fires with a session", async () => { + state.value.refresh = true; + state.value.setMemoryMap = vi.fn((v) => { + state.value.memoryMap = v; + }); + makeRequest.mockResolvedValue({ data: { result: "mem-data" } }); + + render(); + + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + "/memory_map", + { name: "program" }, + "test-session-123" + ) + ); + await waitFor(() => + expect(state.value.setMemoryMap).toHaveBeenCalledWith("mem-data") + ); + }); + + test("swallows fetch errors", async () => { + state.value.refresh = true; + makeRequest.mockRejectedValue(new Error("boom")); + render(); + await waitFor(() => expect(makeRequest).toHaveBeenCalled()); + }); +}); diff --git a/webapp/src/components/GdbComponents/Threads/__tests__/Threads.test.jsx b/webapp/src/components/GdbComponents/Threads/__tests__/Threads.test.jsx new file mode 100644 index 0000000..4822ba4 --- /dev/null +++ b/webapp/src/components/GdbComponents/Threads/__tests__/Threads.test.jsx @@ -0,0 +1,77 @@ +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import { vi } from "vitest"; + +vi.mock("../../../../context/DataContext.jsx", async () => { + const { createContext, useContext } = await import("react"); + const DataContext = createContext({ refresh: false }); + return { + DataContext, + DataState: () => useContext(DataContext), + }; +}); +vi.mock("../../../../api", () => ({ + default: { post: vi.fn() }, + makeRequest: vi.fn(), +})); + +import api from "../../../../api"; +import { DataContext } from "../../../../context/DataContext.jsx"; +import Threads from "../Threads.jsx"; + +const renderWithContext = (refresh) => + render( + + + + ); + +describe("Threads", () => { + beforeEach(() => { + api.post.mockReset(); + api.post.mockResolvedValue({ data: { success: true, result: "thread-1" } }); + }); + + test("shows no threads when refresh has not fired", () => { + renderWithContext(false); + expect(screen.getByText("No active threads.")).toBeInTheDocument(); + expect(api.post).not.toHaveBeenCalled(); + }); + + test("shows loading state while the request is in flight", () => { + let resolvePost; + api.post.mockReturnValue( + new Promise((res) => { + resolvePost = res; + }) + ); + renderWithContext(true); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + act(() => { + resolvePost({ data: { success: true, result: "thread-1" } }); + }); + }); + + test("renders fetched threads", async () => { + renderWithContext(true); + expect(await screen.findByText("thread-1")).toBeInTheDocument(); + }); + + test("shows server error message when result is not successful", async () => { + api.post.mockResolvedValue({ data: { success: false, error: "fetch failed" } }); + renderWithContext(true); + expect(await screen.findByText("fetch failed")).toBeInTheDocument(); + }); + + test("shows generic error when the request rejects", async () => { + api.post.mockRejectedValue(new Error("boom")); + renderWithContext(true); + expect(await screen.findByText("Failed to fetch threads")).toBeInTheDocument(); + }); + + test("treats a no-threads result as empty", async () => { + api.post.mockResolvedValue({ data: { success: true, result: "No threads found" } }); + renderWithContext(true); + expect(await screen.findByText("No active threads.")).toBeInTheDocument(); + }); +}); diff --git a/webapp/src/components/Stack/__tests__/Stack.test.jsx b/webapp/src/components/Stack/__tests__/Stack.test.jsx index 29540fd..42ab18f 100644 --- a/webapp/src/components/Stack/__tests__/Stack.test.jsx +++ b/webapp/src/components/Stack/__tests__/Stack.test.jsx @@ -1,23 +1,98 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; -import Stack from "../Stack.jsx"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { vi } from "vitest"; -vi.mock("../../../context/DataContext.jsx", () => ({ - DataState: () => ({ +const state = vi.hoisted(() => ({ + value: { refresh: false, stack: [], setStack: vi.fn(), - sessionId: 'test-session-123', + sessionId: "test-session-123", sessionLoading: false, sessionError: null, createSession: vi.fn(), clearSessionError: vi.fn(), - }), + }, +})); + +vi.mock("../../../context/DataContext.jsx", () => ({ + DataState: () => state.value, +})); +vi.mock("../../../api", () => ({ + makeRequest: vi.fn(), })); -test("renders Stack component with stack items", () => { - render(); +import { makeRequest } from "../../../api"; +import Stack from "../Stack.jsx"; + +describe("Stack", () => { + beforeEach(() => { + state.value = { + refresh: false, + stack: [], + setStack: vi.fn(), + sessionId: "test-session-123", + sessionLoading: false, + sessionError: null, + createSession: vi.fn(), + clearSessionError: vi.fn(), + }; + makeRequest.mockReset(); + }); + + test("renders Stack heading with active session", () => { + render(); + expect(screen.getByText(/Stack/i)).toBeInTheDocument(); + }); + + test("shows loading state while initializing", () => { + state.value.sessionLoading = true; + render(); + expect(screen.getByText("Initializing debug session...")).toBeInTheDocument(); + }); + + test("shows error banner and starts a new session", () => { + state.value.sessionError = "Session failed"; + render(); + expect(screen.getByText("Session failed")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start New Session")); + expect(state.value.clearSessionError).toHaveBeenCalled(); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("shows no-session state and starts a debug session", () => { + state.value.sessionId = null; + render(); + expect(screen.getByText("No active session.")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Start Debug Session")); + expect(state.value.createSession).toHaveBeenCalled(); + }); + + test("fetches stack trace when refresh fires with a session", async () => { + state.value.refresh = true; + state.value.setStack = vi.fn((v) => { + state.value.stack = v; + }); + makeRequest.mockResolvedValue({ data: { result: "stack-data" } }); + + render(); + + await waitFor(() => + expect(makeRequest).toHaveBeenCalledWith( + "/stack_trace", + { name: "program" }, + "test-session-123" + ) + ); + await waitFor(() => + expect(state.value.setStack).toHaveBeenCalledWith("stack-data") + ); + }); - const stackContainer = screen.getByText(/Stack/i); - expect(stackContainer).toBeInTheDocument(); + test("swallows fetch errors", async () => { + state.value.refresh = true; + makeRequest.mockRejectedValue(new Error("boom")); + render(); + await waitFor(() => expect(makeRequest).toHaveBeenCalled()); + }); }); diff --git a/webapp/src/pages/Demo/__tests__/Demo.test.jsx b/webapp/src/pages/Demo/__tests__/Demo.test.jsx new file mode 100644 index 0000000..5a5a4fa --- /dev/null +++ b/webapp/src/pages/Demo/__tests__/Demo.test.jsx @@ -0,0 +1,214 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { vi } from "vitest"; + +vi.mock("../../../api", () => ({ + default: { post: vi.fn() }, + setSessionIdForApi: vi.fn(), + onSessionExpired: vi.fn(), + makeRequest: vi.fn(), +})); + +import api from "../../../api"; +import Demo from "../Demo.jsx"; + +// Fix the mock-mode delay to 375ms so findBy* polling stays deterministic. +vi.spyOn(Math, "random").mockReturnValue(0.5); + +const submitCommand = (input, cmd) => { + fireEvent.change(input, { target: { value: cmd } }); + fireEvent.submit(input.closest("form")); +}; + +describe("Demo", () => { + beforeEach(() => { + api.post.mockReset(); + api.post.mockResolvedValue({ + data: { success: true, session_id: "sess-1", result: "ok" }, + }); + }); + + test("renders two panels in mock mode by default", async () => { + render(); + + expect(screen.getByText("Multi-User Session Demo")).toBeInTheDocument(); + expect(screen.getByText(/MOCK MODE/)).toBeInTheDocument(); + expect(screen.getByText("Panel A")).toBeInTheDocument(); + expect(screen.getByText("Panel B")).toBeInTheDocument(); + + // Both panels create a mock session without touching the backend. + expect(await screen.findAllByText(/Mock session created:/)).toHaveLength(2); + expect(screen.getAllByText(/Session:/)).toHaveLength(2); + expect(api.post).not.toHaveBeenCalled(); + }); + + test("submitting commands in mock mode returns simulated output", async () => { + render(); + await screen.findAllByText(/Mock session created:/); + const input = screen.getAllByPlaceholderText("Enter GDB command...")[0]; + + submitCommand(input, "run"); + expect(await screen.findByText(/Starting program:/)).toBeInTheDocument(); + expect(screen.getByText("(gdb) run")).toBeInTheDocument(); + }); + + test("empty command does not submit", async () => { + render(); + await screen.findAllByText(/Mock session created:/); + const input = screen.getAllByPlaceholderText("Enter GDB command...")[0]; + + submitCommand(input, " "); + // A blank command is ignored — no input log line is appended. + expect(screen.queryByText("(gdb) ")).not.toBeInTheDocument(); + }); + + test("mock mode handles break/watch/print/info/unknown commands", async () => { + render(); + await screen.findAllByText(/Mock session created:/); + const input = screen.getAllByPlaceholderText("Enter GDB command...")[0]; + + submitCommand(input, "break 10"); + expect(await screen.findByText(/Breakpoint 3 at 0x400550/)).toBeInTheDocument(); + + submitCommand(input, "watch x"); + expect( + await screen.findByText(/Hardware watchpoint 4: x/) + ).toBeInTheDocument(); + + submitCommand(input, "print val"); + expect(await screen.findByText("$1 = 42")).toBeInTheDocument(); + + submitCommand(input, "info locals"); + expect(await screen.findByText(/i = 5/)).toBeInTheDocument(); + + submitCommand(input, "xyzzy"); + expect( + await screen.findByText(/No symbol table is loaded/) + ).toBeInTheDocument(); + }); + + test("toggling to live mode creates sessions via the backend", async () => { + render(); + fireEvent.click(screen.getByRole("checkbox")); + + expect(screen.getByText("LIVE")).toBeInTheDocument(); + expect(screen.queryByText(/MOCK MODE/)).not.toBeInTheDocument(); + expect(await screen.findAllByText(/Session created: sess-1/)).toHaveLength(2); + expect(api.post).toHaveBeenCalledWith("/create_session"); + }); + + test("live mode surfaces server capacity error (503)", async () => { + api.post.mockRejectedValue({ response: { status: 503 } }); + render(); + fireEvent.click(screen.getByRole("checkbox")); + + // Both panels fail, so the error renders twice. + expect(await screen.findAllByText(/Server capacity reached/)).toHaveLength(2); + expect(screen.getAllByText("Recreate Session").length).toBeGreaterThan(0); + }); + + test("live mode surfaces rate-limit error (429)", async () => { + api.post.mockRejectedValue({ response: { status: 429 } }); + render(); + fireEvent.click(screen.getByRole("checkbox")); + + expect(await screen.findAllByText(/Rate limited/)).toHaveLength(2); + }); + + test("live mode surfaces generic connection error", async () => { + api.post.mockRejectedValue({ message: "boom" }); + render(); + fireEvent.click(screen.getByRole("checkbox")); + + expect(await screen.findAllByText("boom")).toHaveLength(2); + }); + + test("live mode sends gdb_command and shows the result", async () => { + render(); + fireEvent.click(screen.getByRole("checkbox")); + await screen.findAllByText(/Session created: sess-1/); + + const input = screen.getAllByPlaceholderText("Enter GDB command...")[0]; + submitCommand(input, "info locals"); + + await waitFor(() => + expect(api.post).toHaveBeenCalledWith( + "/gdb_command", + expect.objectContaining({ command: "info locals" }) + ) + ); + expect(await screen.findByText("ok")).toBeInTheDocument(); + }); + + test("live mode shows server error result text", async () => { + api.post + .mockResolvedValueOnce({ data: { success: true, session_id: "sess-1" } }) + .mockResolvedValueOnce({ data: { success: true, session_id: "sess-2" } }) + .mockResolvedValueOnce({ data: { success: false, error: "boom" } }); + render(); + fireEvent.click(screen.getByRole("checkbox")); + await screen.findAllByText(/Session created:/); + + const input = screen.getAllByPlaceholderText("Enter GDB command...")[0]; + submitCommand(input, "next"); + expect(await screen.findByText("Error: boom")).toBeInTheDocument(); + }); + + test("live mode clears session on 404 response", async () => { + api.post + .mockResolvedValueOnce({ data: { success: true, session_id: "sess-1" } }) + .mockResolvedValueOnce({ data: { success: true, session_id: "sess-2" } }) + .mockRejectedValueOnce({ response: { status: 404 }, message: "gone" }); + render(); + fireEvent.click(screen.getByRole("checkbox")); + await screen.findAllByText(/Session created:/); + + const input = screen.getAllByPlaceholderText("Enter GDB command...")[0]; + submitCommand(input, "next"); + + expect( + await screen.findByText( + "Session expired or not found. Please start a new debug session." + ) + ).toBeInTheDocument(); + expect( + screen.getByText("Session expired or not found. Please reset the session.") + ).toBeInTheDocument(); + }); + + test("live mode reports conflict on 409 response", async () => { + api.post + .mockResolvedValueOnce({ data: { success: true, session_id: "sess-1" } }) + .mockResolvedValueOnce({ data: { success: true, session_id: "sess-2" } }) + .mockRejectedValueOnce({ + response: { status: 409 }, + message: "busy", + }); + render(); + fireEvent.click(screen.getByRole("checkbox")); + await screen.findAllByText(/Session created:/); + + const input = screen.getAllByPlaceholderText("Enter GDB command...")[0]; + submitCommand(input, "run"); + + expect( + await screen.findByText( + "Conflict: GDB is currently running a program or compiler is busy." + ) + ).toBeInTheDocument(); + }); + + test("live mode reset session ends the old one and creates a new one", async () => { + render(); + fireEvent.click(screen.getByRole("checkbox")); + await screen.findAllByText(/Session created: sess-1/); + + fireEvent.click(screen.getAllByText("Reset Session")[0]); + await waitFor(() => + expect(api.post).toHaveBeenCalledWith( + "/end_session", + expect.objectContaining({ session_id: expect.any(String) }) + ) + ); + }); +}); diff --git a/webapp/src/pages/Login/__tests__/Login.test.jsx b/webapp/src/pages/Login/__tests__/Login.test.jsx new file mode 100644 index 0000000..dc1a7ac --- /dev/null +++ b/webapp/src/pages/Login/__tests__/Login.test.jsx @@ -0,0 +1,37 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { vi } from "vitest"; + +const navigate = vi.fn(); +vi.mock("react-router-dom", () => ({ + useNavigate: () => navigate, +})); + +import Login from "../Login.jsx"; + +describe("Login", () => { + beforeEach(() => { + navigate.mockClear(); + }); + + test("renders heading, subtitle and submit button", () => { + render(); + expect(screen.getByText("Welcome to GDB-UI")).toBeInTheDocument(); + expect(screen.getByText("Demo / No auth yet")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Continue to Debugger" }) + ).toBeInTheDocument(); + }); + + test("submitting the form navigates to /debug", () => { + render(); + fireEvent.submit(screen.getByRole("form", { name: "Login form" })); + expect(navigate).toHaveBeenCalledWith("/debug"); + }); + + test("clicking the back button navigates to /", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /back/i })); + expect(navigate).toHaveBeenCalledWith("/"); + }); +}); diff --git a/webapp/src/setupTests.js b/webapp/src/setupTests.js index 3555300..6f5b4f5 100644 --- a/webapp/src/setupTests.js +++ b/webapp/src/setupTests.js @@ -1,6 +1,10 @@ // Import jest-dom to extend Jest with custom matchers for DOM node assertions import "@testing-library/jest-dom"; +// jsdom does not implement scrollIntoView; components that auto-scroll log +// panels (e.g. Demo.jsx) call it in effects, so stub it to a no-op. +Element.prototype.scrollIntoView = () => {}; + // Optional: Configure or set up global settings if needed // For example, you might want to configure a mock server or add global variables. diff --git a/webapp/vite.config.js b/webapp/vite.config.js index 6f87ae8..60f1875 100644 --- a/webapp/vite.config.js +++ b/webapp/vite.config.js @@ -9,6 +9,15 @@ export default defineConfig({ environment: "jsdom", setupFiles: "./src/setupTests.js", include: ["src/**/*.{test,spec}.{jsx,ts}"], // Moved out of the nested test object + coverage: { + reporter: ["text", "json", "html"], + thresholds: { + statements: 80, + branches: 80, + lines: 80, + functions: 75, + }, + }, }, server: { host: "0.0.0.0",