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/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();
+});
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",