From 0585f091550b4fc9c011c2b1a2ecdc58c7efab40 Mon Sep 17 00:00:00 2001 From: NancieDev Date: Sat, 29 Aug 2026 12:40:47 +0000 Subject: [PATCH] test: add dismissal-persistence test to NetworkMismatchBanner - Add dismiss button to NetworkMismatchBanner with aria-label - Persist dismissed state to localStorage under a dedicated key (comebackhere-network-mismatch-dismissed) separate from the network selection key so the two concerns don't interfere - Clear the dismissed flag whenever the mismatch resolves so the banner re-appears if the user creates a new mismatch later - Add 11 tests covering: visibility (no mismatch / checking / mismatch), dismiss button presence, immediate hide on click, localStorage write, remount suppression from stored flag, flag cleared on resolve, and re-appearance after resolve+rematch Closes # --- .../src/components/NetworkMismatchBanner.css | 22 +++ .../components/NetworkMismatchBanner.test.tsx | 177 ++++++++++++++++++ .../src/components/NetworkMismatchBanner.tsx | 49 ++++- 3 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/NetworkMismatchBanner.test.tsx diff --git a/frontend/src/components/NetworkMismatchBanner.css b/frontend/src/components/NetworkMismatchBanner.css index 1c7bbaa..be7bf3e 100644 --- a/frontend/src/components/NetworkMismatchBanner.css +++ b/frontend/src/components/NetworkMismatchBanner.css @@ -13,6 +13,28 @@ gap: 12px; } +.network-mismatch-banner__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.network-mismatch-banner__dismiss { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + color: #d32f2f; + padding: 0 4px; + line-height: 1; + opacity: 0.7; + transition: opacity 0.15s; +} + +.network-mismatch-banner__dismiss:hover { + opacity: 1; +} + .network-mismatch-banner__title { margin: 0; font-size: 18px; diff --git a/frontend/src/components/NetworkMismatchBanner.test.tsx b/frontend/src/components/NetworkMismatchBanner.test.tsx new file mode 100644 index 0000000..d143363 --- /dev/null +++ b/frontend/src/components/NetworkMismatchBanner.test.tsx @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import NetworkMismatchBanner from "./NetworkMismatchBanner"; + +// --------------------------------------------------------------------------- +// Mock useNetwork so tests control the mismatch state independently of the +// real Freighter / localStorage logic inside the hook. +// --------------------------------------------------------------------------- +const mockUseNetwork = vi.fn(); + +vi.mock("../hooks/useNetwork", () => ({ + useNetwork: () => mockUseNetwork(), +})); + +// Storage key used by the component (must match the constant in the source). +const DISMISS_STORAGE_KEY = "comebackhere-network-mismatch-dismissed"; + +function matchedNetworkState() { + return { + hasNetworkMismatch: false, + network: "testnet", + walletNetwork: "testnet", + isCheckingWallet: false, + }; +} + +function mismatchedNetworkState() { + return { + hasNetworkMismatch: true, + network: "testnet", + walletNetwork: "mainnet", + isCheckingWallet: false, + }; +} + +function checkingWalletState() { + return { + hasNetworkMismatch: false, + network: "testnet", + walletNetwork: null, + isCheckingWallet: true, + }; +} + +describe("NetworkMismatchBanner", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + // ------------------------------------------------------------------------- + // Visibility + // ------------------------------------------------------------------------- + describe("visibility", () => { + it("renders nothing when there is no network mismatch", () => { + mockUseNetwork.mockReturnValue(matchedNetworkState()); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing while the wallet network is still being checked", () => { + mockUseNetwork.mockReturnValue(checkingWalletState()); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders the banner when there is a network mismatch", () => { + mockUseNetwork.mockReturnValue(mismatchedNetworkState()); + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("Network Mismatch")).toBeInTheDocument(); + }); + + it("shows app and wallet network names in the banner", () => { + mockUseNetwork.mockReturnValue(mismatchedNetworkState()); + render(); + // The component renders each network name twice (in prose + in the + // details grid), so use getAllByText. + expect(screen.getAllByText("testnet").length).toBeGreaterThan(0); + expect(screen.getAllByText("mainnet").length).toBeGreaterThan(0); + }); + }); + + // ------------------------------------------------------------------------- + // Dismiss button + // ------------------------------------------------------------------------- + describe("dismiss button", () => { + it("renders a dismiss button when the banner is visible", () => { + mockUseNetwork.mockReturnValue(mismatchedNetworkState()); + render(); + expect( + screen.getByRole("button", { name: /dismiss network mismatch banner/i }) + ).toBeInTheDocument(); + }); + + it("hides the banner immediately when the dismiss button is clicked", () => { + mockUseNetwork.mockReturnValue(mismatchedNetworkState()); + render(); + + fireEvent.click( + screen.getByRole("button", { name: /dismiss network mismatch banner/i }) + ); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // Dismiss persistence via localStorage + // ------------------------------------------------------------------------- + describe("dismiss persistence", () => { + it("persists the dismissed state to localStorage on dismiss", () => { + mockUseNetwork.mockReturnValue(mismatchedNetworkState()); + render(); + + fireEvent.click( + screen.getByRole("button", { name: /dismiss network mismatch banner/i }) + ); + + expect(localStorage.getItem(DISMISS_STORAGE_KEY)).toBe("true"); + }); + + it("does not show the banner on remount when already dismissed in localStorage", () => { + // Simulate a previously dismissed state (e.g. from a prior page load). + localStorage.setItem(DISMISS_STORAGE_KEY, "true"); + + mockUseNetwork.mockReturnValue(mismatchedNetworkState()); + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("uses the storage key that matches the network storage key constant", () => { + // The dismissed key should be distinct from the network selection key so + // clearing the dismissed flag cannot accidentally affect network selection. + expect(DISMISS_STORAGE_KEY).not.toBe("comebackhere-network"); + }); + + it("clears the dismissed flag from localStorage when the mismatch resolves", () => { + // Start dismissed. + localStorage.setItem(DISMISS_STORAGE_KEY, "true"); + + // First render: mismatch is present but already dismissed. + mockUseNetwork.mockReturnValue(mismatchedNetworkState()); + const { rerender } = render(); + + // Simulate the mismatch resolving (user fixed their wallet). + mockUseNetwork.mockReturnValue(matchedNetworkState()); + rerender(); + + expect(localStorage.getItem(DISMISS_STORAGE_KEY)).toBeNull(); + }); + + it("re-shows the banner after the mismatch resolves and then reappears", () => { + // 1. Mismatch present → user dismisses. + mockUseNetwork.mockReturnValue(mismatchedNetworkState()); + const { rerender } = render(); + + fireEvent.click( + screen.getByRole("button", { name: /dismiss network mismatch banner/i }) + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + // 2. Mismatch resolves (clears dismissed flag). + mockUseNetwork.mockReturnValue(matchedNetworkState()); + rerender(); + expect(localStorage.getItem(DISMISS_STORAGE_KEY)).toBeNull(); + + // 3. Mismatch returns → banner should be visible again. + mockUseNetwork.mockReturnValue(mismatchedNetworkState()); + rerender(); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/NetworkMismatchBanner.tsx b/frontend/src/components/NetworkMismatchBanner.tsx index 10b782d..d184f00 100644 --- a/frontend/src/components/NetworkMismatchBanner.tsx +++ b/frontend/src/components/NetworkMismatchBanner.tsx @@ -1,17 +1,62 @@ +import { useState, useEffect } from "react"; import { useNetwork } from "../hooks/useNetwork"; import "./NetworkMismatchBanner.css"; +const DISMISS_STORAGE_KEY = "comebackhere-network-mismatch-dismissed"; + +function getDismissedState(): boolean { + try { + return window.localStorage.getItem(DISMISS_STORAGE_KEY) === "true"; + } catch { + return false; + } +} + export default function NetworkMismatchBanner() { const { hasNetworkMismatch, network, walletNetwork, isCheckingWallet } = useNetwork(); + const [dismissed, setDismissed] = useState(getDismissedState); - if (isCheckingWallet || !hasNetworkMismatch) { + // Clear the dismissed flag whenever the mismatch condition changes (e.g. the + // user switches wallet network and then mismatches again on a new combo). + useEffect(() => { + if (!hasNetworkMismatch) { + // Mismatch is resolved – reset so the banner shows again if it returns. + try { + window.localStorage.removeItem(DISMISS_STORAGE_KEY); + } catch { + // ignore storage errors + } + setDismissed(false); + } + }, [hasNetworkMismatch]); + + if (isCheckingWallet || !hasNetworkMismatch || dismissed) { return null; } + function handleDismiss() { + try { + window.localStorage.setItem(DISMISS_STORAGE_KEY, "true"); + } catch { + // ignore storage errors + } + setDismissed(true); + } + return (
-

Network Mismatch

+
+

Network Mismatch

+ +

Your wallet is connected to {walletNetwork}, but COMEBACKHERE is configured for {network}. Please switch your wallet network or change the