Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/FeeEstimator.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
2 changes: 1 addition & 1 deletion src/components/QRCode.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
34 changes: 29 additions & 5 deletions src/components/SorobanPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -12,25 +18,43 @@ 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(<SorobanPanel />);
render(<SorobanPanel contractId="" onContractIdChange={() => {}} />);
const invokeBtn = screen.getByRole("button", { name: /invoke/i });
expect(invokeBtn).toBeDisabled();
});

it("should show error when invalid JSON args are provided", async () => {
render(<SorobanPanel />);
let currentContractId = "";
const setContractId = (id: string) => {
currentContractId = id;
};

const { rerender } = render(
<SorobanPanel contractId={currentContractId} onContractIdChange={setContractId} />
);

// 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 });

fireEvent.change(contractInput, { target: { value: "C123" } });
fireEvent.change(methodInput, { target: { value: "mint" } });
fireEvent.change(argsInput, { target: { value: "invalid json {" } });

// Rerender with the updated contract ID to propagate prop change
rerender(<SorobanPanel contractId="C123" onContractIdChange={setContractId} />);

expect(invokeBtn).not.toBeDisabled();

fireEvent.click(invokeBtn);
Expand Down
98 changes: 98 additions & 0 deletions src/components/WalletConnectButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WalletConnectButton />);
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(<WalletConnectButton />);
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(<WalletConnectButton />);
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(<WalletConnectButton />);
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(<WalletConnectButton />);
expect(screen.getByText("Connection failed")).toBeInTheDocument();

const clearBtn = screen.getByRole("button", { name: "Clear error" });
expect(clearBtn).toBeInTheDocument();
fireEvent.click(clearBtn);
expect(mockClearError).toHaveBeenCalledTimes(1);
});
});
31 changes: 27 additions & 4 deletions src/components/WalletConnectButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={onOpenModal}
aria-label={`Wallet connected: ${address}. Click to manage.`}
className="inline-flex items-center gap-2 h-8 px-3.5 rounded-lg bg-surface-2 border border-line hover:border-line-2 transition-colors cursor-pointer"
>
<span className="w-2 h-2 rounded-full bg-green shrink-0" />
Expand All @@ -22,8 +25,28 @@ export function WalletConnectButton({
}

return (
<Button size="md" loading={isConnecting} onClick={connectWallet}>
{isConnecting ? "Connecting…" : "Connect Wallet"}
</Button>
<div className="relative flex flex-col items-end">
<Button size="md" loading={isConnecting} onClick={connectWallet}>
{isConnecting ? "Connecting…" : "Connect Wallet"}
</Button>
{!isConnected && error && (
<div className="absolute top-[calc(100%+8px)] right-0 z-50 flex items-center gap-2 px-3 py-1.5 bg-surface border border-[rgba(239,68,68,0.15)] rounded-lg shadow-lg text-red text-[11px] whitespace-nowrap animate-in fade-in slide-in-from-top-1 duration-200">
<span>{error}</span>
<button
onClick={clearError}
className="text-red opacity-50 hover:opacity-100 transition-opacity cursor-pointer flex items-center justify-center shrink-0"
aria-label="Clear error"
>
<HugeiconsIcon
icon={Cancel01Icon}
size={12}
color="currentColor"
strokeWidth={2}
/>
</button>
</div>
)}
</div>
);
}

4 changes: 1 addition & 3 deletions src/context/useSorokit.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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 <SorokitProvider>"
);

consoleSpy.mockRestore();
Expand Down
75 changes: 75 additions & 0 deletions src/screens/WalletScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WalletScreen />);

// 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(<WalletScreen />);

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();
});
});
39 changes: 36 additions & 3 deletions src/screens/WalletScreen.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<number | null>(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 (
<div className="flex flex-col gap-6">
Expand All @@ -35,8 +64,12 @@ export function WalletScreen() {
</div>
</div>
{isConnected && (
<Button variant="secondary" size="sm" onClick={disconnectWallet}>
Disconnect
<Button
variant={isConfirming ? "destructive" : "secondary"}
size="sm"
onClick={handleDisconnect}
>
{isConfirming ? "Disconnect?" : "Disconnect"}
</Button>
)}
</div>
Expand Down
Loading