From 4361b0c0a58392e8ff0a390802bc04b5a87b5c0e Mon Sep 17 00:00:00 2001 From: Ada Nwali Date: Sun, 18 Oct 2026 10:05:39 +0100 Subject: [PATCH] test: cover frontend controls and document local sandbox Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs-site/docs/quickstart.md | 33 ++++++++ frontend/src/components/ModeToggle.test.tsx | 60 ++++++++++++++ .../src/components/NetworkSelector.test.tsx | 78 +++++++++++++++++++ .../wallet/DisconnectConfirmModal.test.tsx | 50 ++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 frontend/src/components/ModeToggle.test.tsx create mode 100644 frontend/src/components/NetworkSelector.test.tsx create mode 100644 frontend/src/components/wallet/DisconnectConfirmModal.test.tsx diff --git a/docs-site/docs/quickstart.md b/docs-site/docs/quickstart.md index a5443cb1..8c4d8114 100644 --- a/docs-site/docs/quickstart.md +++ b/docs-site/docs/quickstart.md @@ -65,3 +65,36 @@ events.onmessage = (e) => console.log(JSON.parse(e.data)); Next: [Core concepts](/core-concepts) or the [interactive playground](/api-reference/playground). + +## Running in Local Sandbox Mode + +The local mock sandbox lets you run the frontend and backend with seeded demo +data, without a funded Testnet wallet or live Soroban RPC. From the repository +root, install dependencies once and start the full stack: + +```bash +npm install +npm run dev:mock +``` + +This starts PostgreSQL, Redis, and the mock Soroban RPC, prepares and seeds the +local database with `scripts/seed-mock-data.ts`, then starts the backend and +frontend. The backend is launched with `SANDBOX_MODE_ENABLED=true` by the +`dev:mock:start` script. The backend configuration uses this variable name +(rather than `SANDBOX_MODE`). + +To run the seed script directly, first ensure the local database is running and +its schema has been prepared, then run this command from the repository root: + +```bash +npx tsx scripts/seed-mock-data.ts +``` + +The script uses `DATABASE_URL` if set; otherwise, it defaults to the local +sandbox database at `******localhost:5433/flowfi`. + +Once the services are running, open the app at [http://localhost:3000](http://localhost:3000). +Select **Sandbox** in the frontend network selector to use the local mock RPC +for network-dependent features. The mock RPC does not support transaction +simulation, so use the Testnet workflow above when testing real Stellar +transactions. diff --git a/frontend/src/components/ModeToggle.test.tsx b/frontend/src/components/ModeToggle.test.tsx new file mode 100644 index 00000000..2ce2e60d --- /dev/null +++ b/frontend/src/components/ModeToggle.test.tsx @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ThemeProvider } from "next-themes"; +import { ModeToggle } from "./ModeToggle"; + +function renderModeToggle() { + return render( + + + , + ); +} + +afterEach(() => { + localStorage.clear(); + document.documentElement.className = ""; +}); + +describe("ModeToggle", () => { + it("toggles themes and persists the selected preference", async () => { + const user = userEvent.setup(); + renderModeToggle(); + + const toggle = await screen.findByRole("button", { name: "Switch to dark mode" }); + expect(toggle.querySelector("svg.text-yellow-500")).toBeInTheDocument(); + expect(toggle.querySelector("svg.text-blue-400")).toBeInTheDocument(); + + await user.click(toggle); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeInTheDocument(); + expect(localStorage.getItem("theme")).toBe("dark"); + expect(document.documentElement).toHaveClass("dark"); + }); + + await user.click(screen.getByRole("button", { name: "Switch to light mode" })); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Switch to dark mode" })).toBeInTheDocument(); + expect(localStorage.getItem("theme")).toBe("light"); + expect(document.documentElement).not.toHaveClass("dark"); + }); + }); + + it("can be focused and activated from the keyboard", async () => { + const user = userEvent.setup(); + renderModeToggle(); + + const toggle = await screen.findByRole("button", { name: "Switch to dark mode" }); + await user.tab(); + expect(toggle).toHaveFocus(); + + await user.keyboard("{Enter}"); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeInTheDocument(); + expect(localStorage.getItem("theme")).toBe("dark"); + }); + }); +}); diff --git a/frontend/src/components/NetworkSelector.test.tsx b/frontend/src/components/NetworkSelector.test.tsx new file mode 100644 index 00000000..6f4a01d8 --- /dev/null +++ b/frontend/src/components/NetworkSelector.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const useNetworkMock = vi.fn(); + +vi.mock("@/context/NetworkContext", () => ({ + useNetwork: () => useNetworkMock(), +})); + +import { NetworkSelector } from "./NetworkSelector"; + +describe("NetworkSelector", () => { + const setNetworkId = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + useNetworkMock.mockReturnValue({ networkId: "testnet", setNetworkId }); + }); + + it("shows the active Testnet badge and available Stellar networks", () => { + const { container } = render(); + const selector = screen.getByRole("combobox", { + name: "Active Stellar network", + }); + const badge = container.querySelector("label span"); + + expect(selector).toHaveValue("testnet"); + expect(badge).toHaveClass("bg-orange-400"); + expect(screen.getByRole("option", { name: "Testnet" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Mainnet" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Futurenet" })).toBeInTheDocument(); + }); + + it("uses the active network's badge color", () => { + useNetworkMock.mockReturnValue({ networkId: "mainnet", setNetworkId }); + const { container } = render(); + + expect(container.querySelector("label span")).toHaveClass("bg-emerald-400"); + }); + + it("sets the selected network", async () => { + const user = userEvent.setup(); + render(); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Active Stellar network" }), + "mainnet", + ); + + expect(setNetworkId).toHaveBeenCalledWith("mainnet"); + }); + + it("keeps the native selector keyboard accessible", async () => { + const user = userEvent.setup(); + render(); + const selector = screen.getByRole("combobox", { + name: "Active Stellar network", + }); + const handleKeyDown = vi.fn(); + selector.addEventListener("keydown", handleKeyDown); + + await user.tab(); + expect(selector).toHaveFocus(); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{Enter}"); + + expect(selector).toHaveFocus(); + expect(handleKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "ArrowDown" })); + expect(handleKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" })); + expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([ + "Testnet", + "Futurenet", + "Mainnet", + "Sandbox", + ]); + }); +}); diff --git a/frontend/src/components/wallet/DisconnectConfirmModal.test.tsx b/frontend/src/components/wallet/DisconnectConfirmModal.test.tsx new file mode 100644 index 00000000..15c7b07c --- /dev/null +++ b/frontend/src/components/wallet/DisconnectConfirmModal.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DisconnectConfirmModal } from "./DisconnectConfirmModal"; + +describe("DisconnectConfirmModal", () => { + it("confirms wallet disconnection when Disconnect is selected", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + const onClose = vi.fn(); + + render( + , + ); + + expect(screen.getByText("GTESTWALLETADDRESS")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Disconnect" })); + + expect(onConfirm).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("closes without disconnecting when Cancel is selected", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + const onClose = vi.fn(); + + render(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledOnce(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("closes without disconnecting when Escape is pressed", async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + const onClose = vi.fn(); + + render(); + await user.keyboard("{Escape}"); + + expect(onClose).toHaveBeenCalledOnce(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +});