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
89 changes: 86 additions & 3 deletions src/context/SorokitProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
import { useSorokit } from "./useSorokit";

const TestComponent = () => {
const { address, account, balances, error, connectWallet, disconnectWallet, switchNetwork } = useSorokit();

Check failure on line 12 in src/context/SorokitProvider.test.tsx

View workflow job for this annotation

GitHub Actions / test (22.x)

The value assigned to 'error' is not used in subsequent statements

Check failure on line 12 in src/context/SorokitProvider.test.tsx

View workflow job for this annotation

GitHub Actions / test (22.x)

The value assigned to 'balances' is not used in subsequent statements

Check failure on line 12 in src/context/SorokitProvider.test.tsx

View workflow job for this annotation

GitHub Actions / test (22.x)

The value assigned to 'account' is not used in subsequent statements

Check failure on line 12 in src/context/SorokitProvider.test.tsx

View workflow job for this annotation

GitHub Actions / test (22.x)

The value assigned to 'address' is not used in subsequent statements

Check failure on line 12 in src/context/SorokitProvider.test.tsx

View workflow job for this annotation

GitHub Actions / test (20.x)

The value assigned to 'error' is not used in subsequent statements

Check failure on line 12 in src/context/SorokitProvider.test.tsx

View workflow job for this annotation

GitHub Actions / test (20.x)

The value assigned to 'balances' is not used in subsequent statements

Check failure on line 12 in src/context/SorokitProvider.test.tsx

View workflow job for this annotation

GitHub Actions / test (20.x)

The value assigned to 'account' is not used in subsequent statements

Check failure on line 12 in src/context/SorokitProvider.test.tsx

View workflow job for this annotation

GitHub Actions / test (20.x)

The value assigned to 'address' is not used in subsequent statements

const { address, account, balances, connectWallet, disconnectWallet, switchNetwork, refreshAccount, isLoadingAccount, error, errorHistory } = useSorokit();

return (
<div>
<div data-testid="address">{address || "none"}</div>
<div data-testid="account">{account ? account.sequence : "none"}</div>
<div data-testid="balances">{balances.length}</div>
<div data-testid="error">{error || "none"}</div>
<div data-testid="isLoadingAccount">{isLoadingAccount ? "true" : "false"}</div>
<div data-testid="error">{error || "none"}</div>
<div data-testid="errorHistoryCount">{errorHistory.length}</div>
Expand Down Expand Up @@ -83,7 +86,10 @@
} as unknown as ReturnType<typeof getClient>;
});

it("disconnectWallet clears address, account, and balances", async () => {
it("disconnectWallet clears address, account, balances, and error", async () => {
mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account error" });
mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Balances error" });

renderWithProvider(<TestComponent />, { client: mockClient });

const connectBtn = screen.getByText("Connect");
Expand All @@ -96,8 +102,7 @@
expect(screen.getByTestId("address")).toHaveTextContent("GABC");

await waitFor(() => {
expect(screen.getByTestId("account")).toHaveTextContent("100");
expect(screen.getByTestId("balances")).toHaveTextContent("1");
expect(screen.getByTestId("error")).toHaveTextContent("Account error; Balances error");
});

await act(async () => {
Expand All @@ -107,6 +112,84 @@
expect(screen.getByTestId("address")).toHaveTextContent("none");
expect(screen.getByTestId("account")).toHaveTextContent("none");
expect(screen.getByTestId("balances")).toHaveTextContent("0");
expect(screen.getByTestId("error")).toHaveTextContent("none");
});

it("combines error strings when both getAccount and getBalances fail", async () => {
mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account failed" });
mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Balances failed" });

renderWithProvider(<TestComponent />, { client: mockClient });

await act(async () => {
fireEvent.click(screen.getByText("Connect"));
});

await waitFor(() => {
expect(screen.getByTestId("error")).toHaveTextContent("Account failed; Balances failed");
});
});

it("shows single error when only getAccount fails", async () => {
mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account not found" });
mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: [{ asset: "XLM", balance: "10" }], error: null });

renderWithProvider(<TestComponent />, { client: mockClient });

await act(async () => {
fireEvent.click(screen.getByText("Connect"));
});

await waitFor(() => {
expect(screen.getByTestId("error")).toHaveTextContent("Account not found");
expect(screen.getByTestId("balances")).toHaveTextContent("1");
});
});

it("shows single error when only getBalances fails", async () => {
mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: { sequence: "100" }, error: null });
mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Failed to fetch balances" });

renderWithProvider(<TestComponent />, { client: mockClient });

await act(async () => {
fireEvent.click(screen.getByText("Connect"));
});

await waitFor(() => {
expect(screen.getByTestId("error")).toHaveTextContent("Failed to fetch balances");
expect(screen.getByTestId("account")).toHaveTextContent("100");
});
});

it("clears error from previous session on reconnect", async () => {
mockClient.account.getAccount = vi.fn().mockResolvedValueOnce({ data: null, error: "Old error" });
mockClient.account.getBalances = vi.fn().mockResolvedValueOnce({ data: [], error: null });

renderWithProvider(<TestComponent />, { client: mockClient });

await act(async () => {
fireEvent.click(screen.getByText("Connect"));
});

await waitFor(() => {
expect(screen.getByTestId("error")).toHaveTextContent("Old error");
});

// Next connect with different address succeeds
mockClient.wallet.connect = vi.fn().mockResolvedValue({ data: { address: "GDEF" }, error: null });
mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: { sequence: "200" }, error: null });
mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: [{ asset: "XLM", balance: "50" }], error: null });

await act(async () => {
fireEvent.click(screen.getByText("Connect"));
});

await waitFor(() => {
expect(screen.getByTestId("address")).toHaveTextContent("GDEF");
expect(screen.getByTestId("error")).toHaveTextContent("none");
expect(screen.getByTestId("account")).toHaveTextContent("200");
});
});

it("connectWallet populates address on success", async () => {
Expand Down
17 changes: 16 additions & 1 deletion src/context/SorokitProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,12 @@ export function SorokitProvider({

// Load account when address changes
useEffect(() => {
if (!address) return;
setError(null);
if (!address) {
setAccount(null);
setBalances([]);
return;
}

let active = true;
const timerId = window.setTimeout(() => {
Expand All @@ -162,6 +167,10 @@ export function SorokitProvider({
if (!active) return;
if (accountRes.data) setAccount(accountRes.data);
if (balancesRes.data) setBalances(balancesRes.data);
const combined = [accountRes.error, balancesRes.error]
.filter(Boolean)
.join("; ");
if (combined) setError(combined);
if (accountRes.error && balancesRes.error) {
reportError(
`${accountRes.error}; ${balancesRes.error}`,
Expand Down Expand Up @@ -227,6 +236,12 @@ export function SorokitProvider({
}, [reportError]);

const disconnectWallet = useCallback(async () => {
await client.wallet.disconnect();
setAddress(null);
setAccount(null);
setBalances([]);
setError(null);
}, [client]);
setIsDisconnecting(true);
try {
// A wallet adapter that throws (e.g. the extension went away
Expand Down
69 changes: 69 additions & 0 deletions src/screens/Dashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,72 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { Dashboard } from "./Dashboard";

vi.mock("@/context/useSorokit", () => ({
useSorokit: vi.fn(() => ({
isConnected: true,
address: "GABC",
account: null,
balances: [],
network: { name: "testnet" },
error: null,
clearError: vi.fn(),
})),
}));

vi.mock("@/screens/WalletScreen", () => ({
WalletScreen: () => <div data-testid="wallet-screen">WalletScreen</div>,
}));

vi.mock("@/screens/AccountScreen", () => ({
AccountScreen: () => <div data-testid="account-screen">AccountScreen</div>,
}));

vi.mock("@/screens/TransactionsScreen", () => ({
TransactionsScreen: () => <div data-testid="transactions-screen">TransactionsScreen</div>,
}));

vi.mock("@/screens/SorobanScreen", () => ({
SorobanScreen: () => <div data-testid="soroban-screen">SorobanScreen</div>,
}));

vi.mock("@/screens/NetworkScreen", () => ({
NetworkScreen: () => <div data-testid="network-screen">NetworkScreen</div>,
}));

describe("Dashboard screen mounting", () => {
it("mounts only the default active screen (wallet) on load", () => {
render(<Dashboard />);

expect(screen.getByTestId("wallet-screen")).toBeInTheDocument();
expect(screen.queryByTestId("account-screen")).not.toBeInTheDocument();
expect(screen.queryByTestId("transactions-screen")).not.toBeInTheDocument();
expect(screen.queryByTestId("soroban-screen")).not.toBeInTheDocument();
expect(screen.queryByTestId("network-screen")).not.toBeInTheDocument();
});

it("unmounts previous screen and mounts only the new active screen when navigating", () => {
render(<Dashboard />);

// Click Account in sidebar
fireEvent.click(screen.getByRole("button", { name: /account/i }));
expect(screen.getByTestId("account-screen")).toBeInTheDocument();
expect(screen.queryByTestId("wallet-screen")).not.toBeInTheDocument();

// Click Transactions
fireEvent.click(screen.getByRole("button", { name: /transactions/i }));
expect(screen.getByTestId("transactions-screen")).toBeInTheDocument();
expect(screen.queryByTestId("account-screen")).not.toBeInTheDocument();

// Click Soroban
fireEvent.click(screen.getByRole("button", { name: /soroban/i }));
expect(screen.getByTestId("soroban-screen")).toBeInTheDocument();
expect(screen.queryByTestId("transactions-screen")).not.toBeInTheDocument();

// Click Network
fireEvent.click(screen.getByRole("button", { name: /network/i }));
expect(screen.getByTestId("network-screen")).toBeInTheDocument();
expect(screen.queryByTestId("soroban-screen")).not.toBeInTheDocument();
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

Expand Down
13 changes: 13 additions & 0 deletions src/screens/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { useState } from "react";
import { Sidebar, type NavSection } from "@/components/Sidebar";
import { TopBar } from "@/components/TopBar";
import { type ComponentType, lazy, Suspense, useCallback, useEffect, useState } from "react";

import { ErrorBoundary } from "@/components/ErrorBoundary";
Expand Down Expand Up @@ -51,6 +54,10 @@ const PAGE_TITLES: Record<NavSection, string> = {
nfts: "NFTs — Sorokit",
};

export function Dashboard() {
const [active, setActive] = useState<NavSection>("wallet");
const [sidebarOpen, setSidebarOpen] = useState(false);

const SCREENS: Record<NavSection, ComponentType> = {
wallet: WalletScreen,
account: AccountScreen,
Expand Down Expand Up @@ -177,6 +184,12 @@ export function Dashboard({
/>
<NetworkBanner active={active} />
<main className="flex-1 min-h-0 overflow-y-auto">
<div className="max-w-[700px] mx-auto px-6 py-8 sm:px-10 sm:py-10 min-h-[300px]">
{active === "wallet" && <WalletScreen />}
{active === "account" && <AccountScreen />}
{active === "transactions" && <TransactionsScreen />}
{active === "soroban" && <SorobanScreen />}
{active === "network" && <NetworkScreen />}
<div
className="mx-auto px-6 py-8 sm:px-10 sm:py-10 min-h-[300px]"
style={{ maxWidth: maxContentWidth }}
Expand Down
Loading