From f4e367606d2fb80bb14d171b542118e2571f556d Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Thu, 25 Jun 2026 06:13:58 +0100 Subject: [PATCH 1/2] fix(WalletConnectButton,WalletScreen): double-click disconnect confirmation, inline connect error display, and aria-label --- src/components/WalletConnectButton.test.tsx | 98 +++++++++++++++++++++ src/components/WalletConnectButton.tsx | 31 ++++++- src/screens/WalletScreen.test.tsx | 75 ++++++++++++++++ src/screens/WalletScreen.tsx | 39 +++++++- 4 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 src/components/WalletConnectButton.test.tsx create mode 100644 src/screens/WalletScreen.test.tsx diff --git a/src/components/WalletConnectButton.test.tsx b/src/components/WalletConnectButton.test.tsx new file mode 100644 index 0000000..6609de1 --- /dev/null +++ b/src/components/WalletConnectButton.test.tsx @@ -0,0 +1,98 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { WalletConnectButton } from "./WalletConnectButton"; +import { useSorokit } from "@/context/useSorokit"; + +vi.mock("@/context/useSorokit", () => ({ + useSorokit: vi.fn(), +})); + +describe("WalletConnectButton", () => { + const mockConnect = vi.fn(); + const mockClearError = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders 'Connect Wallet' when not connected", () => { + (useSorokit as any).mockReturnValue({ + isConnected: false, + isConnecting: false, + address: null, + connectWallet: mockConnect, + error: null, + clearError: mockClearError, + }); + + render(); + expect(screen.getByRole("button", { name: "Connect Wallet" })).toBeInTheDocument(); + }); + + it("triggers connectWallet on click", () => { + (useSorokit as any).mockReturnValue({ + isConnected: false, + isConnecting: false, + address: null, + connectWallet: mockConnect, + error: null, + clearError: mockClearError, + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Connect Wallet" })); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it("renders loading state when connecting", () => { + (useSorokit as any).mockReturnValue({ + isConnected: false, + isConnecting: true, + address: null, + connectWallet: mockConnect, + error: null, + clearError: mockClearError, + }); + + render(); + expect(screen.getByRole("button", { name: "Connecting…" })).toBeInTheDocument(); + }); + + it("renders connected state with correct address and aria-label", () => { + const fullAddress = "GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + (useSorokit as any).mockReturnValue({ + isConnected: true, + isConnecting: false, + address: fullAddress, + connectWallet: mockConnect, + error: null, + clearError: mockClearError, + }); + + render(); + const button = screen.getByRole("button", { + name: `Wallet connected: ${fullAddress}. Click to manage.`, + }); + expect(button).toBeInTheDocument(); + expect(screen.getByText("GABC12...WXYZ")).toBeInTheDocument(); + }); + + it("renders inline error message and handles clearError", () => { + (useSorokit as any).mockReturnValue({ + isConnected: false, + isConnecting: false, + address: null, + connectWallet: mockConnect, + error: "Connection failed", + clearError: mockClearError, + }); + + render(); + expect(screen.getByText("Connection failed")).toBeInTheDocument(); + + const clearBtn = screen.getByRole("button", { name: "Clear error" }); + expect(clearBtn).toBeInTheDocument(); + fireEvent.click(clearBtn); + expect(mockClearError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/WalletConnectButton.tsx b/src/components/WalletConnectButton.tsx index e08fbb8..bab3494 100644 --- a/src/components/WalletConnectButton.tsx +++ b/src/components/WalletConnectButton.tsx @@ -1,18 +1,21 @@ import { useSorokit } from "@/context/useSorokit"; import { Button } from "@/components/ui/Button"; import { truncateAddress } from "@/lib/utils"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Cancel01Icon } from "@hugeicons/core-free-icons"; export function WalletConnectButton({ onOpenModal, }: { onOpenModal?: () => void; }) { - const { isConnected, isConnecting, address, connectWallet } = useSorokit(); + const { isConnected, isConnecting, address, connectWallet, error, clearError } = useSorokit(); if (isConnected && address) { return ( +
+ + {!isConnected && error && ( +
+ {error} + +
+ )} +
); } + diff --git a/src/screens/WalletScreen.test.tsx b/src/screens/WalletScreen.test.tsx new file mode 100644 index 0000000..4d02c82 --- /dev/null +++ b/src/screens/WalletScreen.test.tsx @@ -0,0 +1,75 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { WalletScreen } from "./WalletScreen"; +import { useSorokit } from "@/context/useSorokit"; + +vi.mock("@/context/useSorokit", () => ({ + useSorokit: vi.fn(), +})); + +describe("WalletScreen", () => { + const mockDisconnect = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders active connected state and handles disconnect confirmation", () => { + (useSorokit as any).mockReturnValue({ + address: "GABC123456", + isConnected: true, + disconnectWallet: mockDisconnect, + network: { name: "testnet", rpcUrl: "https://rpc.com" }, + }); + + render(); + + // Check initial connect state is visible + expect(screen.getByText("Connected")).toBeInTheDocument(); + + // Disconnect button should start as "Disconnect" + const disconnectBtn = screen.getByRole("button", { name: "Disconnect" }); + expect(disconnectBtn).toBeInTheDocument(); + expect(disconnectBtn.className).toContain("border-line-2"); // secondary style classes + + // First click should switch button label to "Disconnect?" + fireEvent.click(disconnectBtn); + expect(mockDisconnect).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Disconnect?" })).toBeInTheDocument(); + + // Second click should execute disconnectWallet + fireEvent.click(screen.getByRole("button", { name: "Disconnect?" })); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + it("resets confirmation state to Disconnect after 3 seconds", () => { + (useSorokit as any).mockReturnValue({ + address: "GABC123456", + isConnected: true, + disconnectWallet: mockDisconnect, + network: null, + }); + + render(); + + const disconnectBtn = screen.getByRole("button", { name: "Disconnect" }); + + // First click + fireEvent.click(disconnectBtn); + expect(screen.getByRole("button", { name: "Disconnect?" })).toBeInTheDocument(); + + // Fast-forward 3 seconds + act(() => { + vi.advanceTimersByTime(3000); + }); + + // Label should reset back to "Disconnect" + expect(screen.getByRole("button", { name: "Disconnect" })).toBeInTheDocument(); + expect(mockDisconnect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx index 5773f8c..1b05134 100644 --- a/src/screens/WalletScreen.tsx +++ b/src/screens/WalletScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { useSorokit } from "@/context/useSorokit"; import { Button } from "@/components/ui/Button"; import { Badge } from "@/components/ui/Badge"; @@ -10,6 +10,35 @@ import { AddressDisplay } from "@/components/AddressDisplay"; export function WalletScreen() { const { address, isConnected, disconnectWallet, network } = useSorokit(); + const [isConfirming, setIsConfirming] = useState(false); + const timeoutRef = useRef(null); + + const handleDisconnect = () => { + if (isConfirming) { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setIsConfirming(false); + disconnectWallet(); + } else { + setIsConfirming(true); + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setIsConfirming(false); + }, 3000); + } + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); return (
@@ -35,8 +64,12 @@ export function WalletScreen() {
{isConnected && ( - )} From 4afcae079ea79b91ce8db4119205ab843ecbe1e8 Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Thu, 25 Jun 2026 06:56:40 +0100 Subject: [PATCH 2/2] fix(test): resolve unused imports and failing placeholder assertions in components tests --- src/components/FeeEstimator.test.tsx | 2 +- src/components/QRCode.test.tsx | 2 +- src/components/SorobanPanel.test.tsx | 34 ++++++++++++++++++++++++---- src/context/useSorokit.test.tsx | 4 +--- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/components/FeeEstimator.test.tsx b/src/components/FeeEstimator.test.tsx index e77eeda..9f58a56 100644 --- a/src/components/FeeEstimator.test.tsx +++ b/src/components/FeeEstimator.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { FeeEstimator } from "./FeeEstimator"; diff --git a/src/components/QRCode.test.tsx b/src/components/QRCode.test.tsx index eb2f452..e9fcebf 100644 --- a/src/components/QRCode.test.tsx +++ b/src/components/QRCode.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { QRCode } from "./QRCode"; describe("QRCode", () => { diff --git a/src/components/SorobanPanel.test.tsx b/src/components/SorobanPanel.test.tsx index 47be9e1..b5fb0b3 100644 --- a/src/components/SorobanPanel.test.tsx +++ b/src/components/SorobanPanel.test.tsx @@ -1,6 +1,12 @@ import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { SorobanPanel } from "./SorobanPanel"; +import { useSorokit } from "@/context/useSorokit"; + +// Mock the useSorokit context +vi.mock("@/context/useSorokit", () => ({ + useSorokit: vi.fn(), +})); // Mock the getClient from lib/client vi.mock("../lib/client", () => ({ @@ -12,18 +18,33 @@ vi.mock("../lib/client", () => ({ })); describe("SorobanPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useSorokit).mockReturnValue({ + isConnected: true, + address: "GABC", + } as any); + }); + it("should have invoke button disabled when method is empty", () => { - render(); + render( {}} />); const invokeBtn = screen.getByRole("button", { name: /invoke/i }); expect(invokeBtn).toBeDisabled(); }); it("should show error when invalid JSON args are provided", async () => { - render(); + let currentContractId = ""; + const setContractId = (id: string) => { + currentContractId = id; + }; + + const { rerender } = render( + + ); // Fill out contract ID and method to enable the button - const contractInput = screen.getByPlaceholderText(/c.../i); - const methodInput = screen.getByPlaceholderText(/e\.g\. transfer/i); + const contractInput = screen.getByPlaceholderText(/c\.\.\./i); + const methodInput = screen.getByPlaceholderText(/transfer/i); const argsInput = screen.getByPlaceholderText(/\[.*\]/i); const invokeBtn = screen.getByRole("button", { name: /invoke/i }); @@ -31,6 +52,9 @@ describe("SorobanPanel", () => { fireEvent.change(methodInput, { target: { value: "mint" } }); fireEvent.change(argsInput, { target: { value: "invalid json {" } }); + // Rerender with the updated contract ID to propagate prop change + rerender(); + expect(invokeBtn).not.toBeDisabled(); fireEvent.click(invokeBtn); diff --git a/src/context/useSorokit.test.tsx b/src/context/useSorokit.test.tsx index 5a850ec..4a2952a 100644 --- a/src/context/useSorokit.test.tsx +++ b/src/context/useSorokit.test.tsx @@ -1,8 +1,6 @@ import { renderHook } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import { useSorokit } from "./useSorokit"; -import { SorokitProvider } from "./SorokitProvider"; -import { getClient } from "../lib/client"; // Note: we just need to ensure it throws without the provider. describe("useSorokit", () => { @@ -11,7 +9,7 @@ describe("useSorokit", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); expect(() => renderHook(() => useSorokit())).toThrow( - "useSorokit must be used within a SorokitProvider" + "[sorokit-ui] useSorokit must be used inside " ); consoleSpy.mockRestore();