diff --git a/__tests__/dark-mode-switcher.test.tsx b/__tests__/dark-mode-switcher.test.tsx new file mode 100644 index 0000000..22b0e9c --- /dev/null +++ b/__tests__/dark-mode-switcher.test.tsx @@ -0,0 +1,248 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import DarkModeSwitcher, { + DarkModeSwitcherEmptyState, + type DarkModeSwitcherProps, +} from "@/app/components/DarkModeSwitcher"; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +function renderSwitcher(props: DarkModeSwitcherProps = {}) { + return render(); +} + +// --------------------------------------------------------------------------- +// Empty state (isDarkMode is null / undefined) +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — empty state", () => { + it("renders empty state when isDarkMode is undefined", () => { + renderSwitcher(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("renders empty state when isDarkMode is null", () => { + renderSwitcher({ isDarkMode: null }); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("does NOT render the toggle button in empty state", () => { + renderSwitcher(); + expect(screen.queryByTestId("dark-mode-switcher")).not.toBeInTheDocument(); + }); + + it("empty state has role=region", () => { + renderSwitcher(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toHaveAttribute( + "role", + "region" + ); + }); + + it("empty state has descriptive aria-label", () => { + renderSwitcher(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toHaveAttribute( + "aria-label", + "No theme preferences" + ); + }); + + it("DarkModeSwitcherEmptyState renders standalone", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("DarkModeSwitcherEmptyState accepts className", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state").className).toContain("mt-4"); + }); +}); + +// --------------------------------------------------------------------------- +// Loading state +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — loading state", () => { + it("renders loading state when loading=true", () => { + renderSwitcher({ loading: true }); + expect(screen.getByTestId("dark-mode-switcher")).toBeInTheDocument(); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("data-state", "loading"); + }); + + it("loading state has role=status", () => { + renderSwitcher({ loading: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("role", "status"); + }); + + it("loading state has aria-label 'Loading theme'", () => { + renderSwitcher({ loading: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute( + "aria-label", + "Loading theme" + ); + }); + + it("loading state shows loading text", () => { + renderSwitcher({ loading: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveTextContent("Loading theme..."); + }); + + it("loading overrides the empty state — renders switcher not empty state", () => { + renderSwitcher({ loading: true, isDarkMode: undefined }); + expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument(); + expect(screen.getByTestId("dark-mode-switcher")).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Toggle — dark mode on +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — dark mode active", () => { + it("renders the toggle button", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toBeInTheDocument(); + }); + + it("has role=switch", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("role", "switch"); + }); + + it("has aria-checked=true when dark", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("aria-checked", "true"); + }); + + it("has data-state=dark", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("data-state", "dark"); + }); + + it("defaults aria-label to 'Switch to light mode' when dark", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute( + "aria-label", + "Switch to light mode" + ); + }); + + it("thumb is translated right when dark", () => { + renderSwitcher({ isDarkMode: true }); + expect(screen.getByTestId("dark-mode-switcher-thumb")).toHaveClass("translate-x-5"); + }); +}); + +// --------------------------------------------------------------------------- +// Toggle — light mode +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — light mode active", () => { + it("has aria-checked=false when light", () => { + renderSwitcher({ isDarkMode: false }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("aria-checked", "false"); + }); + + it("has data-state=light", () => { + renderSwitcher({ isDarkMode: false }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("data-state", "light"); + }); + + it("defaults aria-label to 'Switch to dark mode' when light", () => { + renderSwitcher({ isDarkMode: false }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute( + "aria-label", + "Switch to dark mode" + ); + }); + + it("thumb is at translate-x-0 when light", () => { + renderSwitcher({ isDarkMode: false }); + expect(screen.getByTestId("dark-mode-switcher-thumb")).toHaveClass("translate-x-0"); + }); +}); + +// --------------------------------------------------------------------------- +// Interactions +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — interactions", () => { + it("calls onToggle on click", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ isDarkMode: true, onToggle }); + await user.click(screen.getByTestId("dark-mode-switcher")); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("calls onToggle on Space key", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ isDarkMode: false, onToggle }); + screen.getByTestId("dark-mode-switcher").focus(); + await user.keyboard(" "); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("calls onToggle on Enter key", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ isDarkMode: false, onToggle }); + screen.getByTestId("dark-mode-switcher").focus(); + await user.keyboard("{Enter}"); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("does NOT call onToggle when disabled", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ isDarkMode: true, onToggle, disabled: true }); + await user.click(screen.getByTestId("dark-mode-switcher")); + expect(onToggle).not.toHaveBeenCalled(); + }); + + it("does NOT call onToggle when loading", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + renderSwitcher({ loading: true, onToggle }); + // loading state is a span, not a button — no click interaction + expect(onToggle).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +describe("DarkModeSwitcher — props", () => { + it("accepts a custom ariaLabel", () => { + renderSwitcher({ isDarkMode: true, ariaLabel: "Toggle theme" }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute( + "aria-label", + "Toggle theme" + ); + }); + + it("accepts a custom id", () => { + renderSwitcher({ isDarkMode: false, id: "my-switcher" }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("id", "my-switcher"); + }); + + it("applies custom className to the button", () => { + renderSwitcher({ isDarkMode: false, className: "mt-4" }); + expect(screen.getByTestId("dark-mode-switcher").className).toContain("mt-4"); + }); + + it("disabled button has aria-disabled=true", () => { + renderSwitcher({ isDarkMode: true, disabled: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("aria-disabled", "true"); + }); + + it("disabled button has tabIndex=-1", () => { + renderSwitcher({ isDarkMode: false, disabled: true }); + expect(screen.getByTestId("dark-mode-switcher")).toHaveAttribute("tabindex", "-1"); + }); +}); diff --git a/__tests__/dashboard-accessibility.test.tsx b/__tests__/dashboard-accessibility.test.tsx index 20e4ce9..f63b541 100644 --- a/__tests__/dashboard-accessibility.test.tsx +++ b/__tests__/dashboard-accessibility.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import Dashboard from "@/app/dashboard/page"; @@ -374,15 +374,17 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML) render(); - await waitFor(() => { - const expandButton = screen.getByRole("button", { name: /Job #job-1/i }); - expandButton.click(); + const expandButton = await screen.findByRole("button", { + name: /Job #job-1/i, + }); + if (expandButton.getAttribute("aria-expanded") !== "true") { + fireEvent.click(expandButton); + } - waitFor(() => { - const region = screen.getByRole("region", { name: /Details for job #job-1/i }); - expect(region).toBeInTheDocument(); - }); + const region = await screen.findByRole("region", { + name: /Details for job #job-1/i, }); + expect(region).toBeInTheDocument(); }); it("role badges have aria-label describing role", async () => { @@ -467,11 +469,9 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML) render(); - waitFor(() => { - const status = screen.getByRole("status"); - expect(status).toHaveAttribute("aria-live", "polite"); - expect(status).toHaveTextContent("Connect your wallet"); - }); + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveTextContent("Connect your wallet"); }); }); @@ -783,15 +783,17 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML) render(); - await waitFor(() => { - const expandButton = screen.getByRole("button", { name: /Job #job-1/i }); - expandButton.click(); + const expandButton = await screen.findByRole("button", { + name: /Job #job-1/i, + }); + if (expandButton.getAttribute("aria-expanded") !== "true") { + fireEvent.click(expandButton); + } - waitFor(() => { - const milestonesRegion = screen.getByRole("region", { name: "Milestones" }); - expect(milestonesRegion).toBeInTheDocument(); - }); + const milestonesRegion = await screen.findByRole("region", { + name: "Milestones", }); + expect(milestonesRegion).toBeInTheDocument(); }); }); @@ -1095,14 +1097,18 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML) render(); - await waitFor(() => { - const expandButton = screen.getByRole("button", { name: /Job #job-1/i }); - expandButton.click(); + const expandButton = await screen.findByRole("button", { + name: /Job #job-1/i, + }); + if (expandButton.getAttribute("aria-expanded") !== "true") { + fireEvent.click(expandButton); + } - waitFor(() => { - const decorativeText = screen.queryByText("Collapse", { selector: "[aria-hidden='true']" }); - expect(decorativeText).toBeInTheDocument(); + await waitFor(() => { + const decorativeText = screen.queryByText("Collapse", { + selector: "[aria-hidden='true']", }); + expect(decorativeText).toBeInTheDocument(); }); }); @@ -1114,10 +1120,8 @@ describe("Dashboard — accessibility compliance (ARIA, keyboard, semantic HTML) render(); - waitFor(() => { - const status = screen.getByRole("status"); - expect(status).toHaveAttribute("aria-live", "polite"); - }); + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-live", "polite"); }); it("role=alert exposes error messages to screen readers immediately", async () => { diff --git a/__tests__/dashboard-list.test.tsx b/__tests__/dashboard-list.test.tsx new file mode 100644 index 0000000..fec3e5d --- /dev/null +++ b/__tests__/dashboard-list.test.tsx @@ -0,0 +1,425 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Dashboard from "@/app/dashboard/page"; + +const mockUseWallet = vi.fn(); +const mockUseToast = vi.fn(); +const mockUseActionStates = vi.fn(); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: () => mockUseWallet(), +})); + +vi.mock("@/app/context/ToastContext", () => ({ + useToast: () => mockUseToast(), +})); + +vi.mock("@/app/components/Navbar", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/LoadingSkeleton", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/MilestoneCard", () => ({ + default: () =>
, +})); + +vi.mock("@/app/components/EmptyStateCard", () => ({ + default: ({ + testId, + ariaLabel, + title, + description, + icon, + badges, + }: { + testId?: string; + ariaLabel?: string; + title: string; + description: string; + icon?: string; + badges?: string[]; + }) => ( +
+ +

{title}

+

{description}

+
+ {badges?.map((badge) => ( + + {badge} + + ))} +
+
+ ), +})); + +vi.mock("@/app/hooks/useActionStates", () => ({ + useActionStates: () => mockUseActionStates(), +})); + +describe("Dashboard — jobs list (dashboard_list) rendering", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseWallet.mockReturnValue({ + address: "GCLIENTADDRESS1234567890123456789012345678901234", + signTransaction: vi.fn(), + }); + mockUseToast.mockReturnValue({ + showToast: vi.fn(), + toasts: [], + hideToast: vi.fn(), + }); + mockUseActionStates.mockReturnValue({ + getState: () => ({ phase: "idle", error: null, txHash: null }), + isPending: () => false, + setPhase: vi.fn(), + setError: vi.fn(), + setTxHash: vi.fn(), + }); + }); + + type MockJob = ReturnType; + + const mockJobsResponse = ( + jobs: MockJob[] = [], + overrides: Record = {} + ) => ({ + success: true, + data: jobs, + page: 1, + limit: 5, + total: jobs.length, + ...overrides, + }); + + const mockJob = (overrides: Record = {}) => ({ + id: "job-1234567890abcdef", + client: "GCLIENTADDRESS1234567890123456789012345678901234", + freelancer: "GFREELANCERADDRESS12345678901234567890123456", + arbiter: "GARBITERADDRESS12345678901234567890123456789012", + funded: true, + milestones: [ + { index: 0, amount: "250000000", status: "Pending" }, + { index: 1, amount: "500000000", status: "Delivered" }, + ], + tokenSymbol: "USDC", + tokenDecimals: 7, + ...overrides, + }); + + const setupFetch = (response: unknown) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + json: async () => response, + })); + }; + + describe("Loading state", () => { + it("shows LoadingSkeleton while fetching jobs", async () => { + let resolveFetch!: (value: unknown) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + vi.stubGlobal("fetch", vi.fn().mockReturnValue(fetchPromise)); + + render(); + + expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument(); + + await resolveFetch(mockJobsResponse([])); + }); + + it("shows loading skeleton with proper accessible status role", () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + json: async () => mockJobsResponse([]), + })); + + render(); + + const skeleton = screen.getByTestId("loading-skeleton"); + expect(skeleton).toBeInTheDocument(); + }); + }); + + describe("Error state", () => { + it("displays error alert when API fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + json: async () => ({ + success: false, + error: "Failed to fetch jobs", + }), + })); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("dashboard-error-alert")).toBeInTheDocument(); + }); + + expect(screen.getByText("Error loading jobs")).toBeInTheDocument(); + expect(screen.getByText("Failed to fetch jobs")).toBeInTheDocument(); + }); + + it("error alert has proper ARIA attributes", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + json: async () => ({ + success: false, + error: "Backend unavailable", + }), + })); + + render(); + + await waitFor(() => { + const alert = screen.getByTestId("dashboard-error-alert"); + expect(alert).toHaveAttribute("role", "alert"); + expect(alert).toHaveAttribute("aria-live", "assertive"); + }); + }); + }); + + describe("Empty state", () => { + it("shows EmptyStateCard when no jobs found", async () => { + setupFetch(mockJobsResponse([])); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("dashboard-empty-state")).toBeInTheDocument(); + }); + }); + + it("displays correct title and description in empty state", async () => { + setupFetch(mockJobsResponse([])); + + render(); + + await waitFor(() => { + expect(screen.getByText("No jobs found")).toBeInTheDocument(); + expect(screen.getByText(/You don't have any jobs yet/)).toBeInTheDocument(); + }); + }); + + it("shows role badges in empty state", async () => { + setupFetch(mockJobsResponse([])); + + render(); + + await waitFor(() => { + expect(screen.getByText("Client")).toBeInTheDocument(); + expect(screen.getByText("Freelancer")).toBeInTheDocument(); + expect(screen.getByText("Arbiter")).toBeInTheDocument(); + }); + }); + + it("empty state is an accessible region", async () => { + setupFetch(mockJobsResponse([])); + + render(); + + await waitFor(() => { + expect(screen.getByRole("region", { name: "No jobs" })).toBeInTheDocument(); + }); + }); + }); + + describe("Jobs list rendering", () => { + it("renders job list items when jobs exist", async () => { + const jobs = [ + mockJob({ id: "job-1", funded: true }), + mockJob({ id: "job-2", funded: false }), + ]; + setupFetch(mockJobsResponse(jobs)); + + render(); + + await waitFor(() => { + expect(screen.getAllByTestId("dashboard-list-item")).toHaveLength(2); + }); + }); + + it("displays job ID prefix for each job", async () => { + const jobs = [mockJob({ id: "job-abcdef123456" })]; + setupFetch(mockJobsResponse(jobs)); + + render(); + + await waitFor(() => { + // Component slices first 8 chars: "job-abcd" + expect(screen.getByText("Job #job-abcd")).toBeInTheDocument(); + }); + }); + + it("shows funded status for each job", async () => { + const jobs = [ + mockJob({ id: "job-funded", funded: true }), + mockJob({ id: "job-unfunded", funded: false }), + ]; + setupFetch(mockJobsResponse(jobs)); + + render(); + + await waitFor(() => { + expect(screen.getByText("Funded")).toBeInTheDocument(); + expect(screen.getByText("Not funded")).toBeInTheDocument(); + }); + }); + + it("displays role badges for user's roles", async () => { + const jobs = [mockJob({ + id: "job-with-roles", + client: "GCLIENTADDRESS1234567890123456789012345678901234", + freelancer: "GFREELANCERADDRESS12345678901234567890123456", + })]; + setupFetch(mockJobsResponse(jobs)); + + render(); + + await waitFor(() => { + // Check role badges in the list item - they appear as text + const clientBadges = screen.getAllByText("Client"); + const freelancerBadges = screen.getAllByText("Freelancer"); + expect(clientBadges.length).toBeGreaterThanOrEqual(1); + expect(freelancerBadges.length).toBeGreaterThanOrEqual(1); + }); + }); + + it("job list container has proper ARIA label", async () => { + const jobs = [mockJob({ id: "job-1" })]; + setupFetch(mockJobsResponse(jobs)); + + render(); + + await waitFor(() => { + const listRegion = screen.getByRole("region", { name: "Jobs list" }); + expect(listRegion).toBeInTheDocument(); + }); + }); + + it("job items have expand/collapse button with proper ARIA", async () => { + const jobs = [mockJob({ id: "job-expandable" })]; + setupFetch(mockJobsResponse(jobs)); + + render(); + + await waitFor(() => { + const button = screen.getByRole("button", { name: /Job #job-exp/ }); + expect(button).toBeInTheDocument(); + // First job is expanded by default, so aria-expanded is "true" + expect(button).toHaveAttribute("aria-expanded", "true"); + expect(button).toHaveAttribute("aria-controls"); + }); + }); + }); + + describe("Search and filter", () => { + it("has search input with proper ARIA attributes", async () => { + setupFetch(mockJobsResponse([])); + + render(); + + await waitFor(() => { + const searchInput = screen.getByLabelText("Search by contract ID"); + expect(searchInput).toBeInTheDocument(); + expect(searchInput).toHaveAttribute("placeholder", "Search by contract/job ID"); + }); + }); + + it("has role filter tabs with proper ARIA", async () => { + setupFetch(mockJobsResponse([])); + + render(); + + await waitFor(() => { + const filterTabs = screen.getByRole("tablist", { name: "Filter jobs by role" }); + expect(filterTabs).toBeInTheDocument(); + + expect(screen.getByRole("tab", { name: "Filter jobs: All" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Filter jobs: As Client" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Filter jobs: As Freelancer" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Filter jobs: As Arbiter" })).toBeInTheDocument(); + }); + }); + + it("role filter shows active state", async () => { + setupFetch(mockJobsResponse([])); + + render(); + + await waitFor(() => { + const allTab = screen.getByRole("tab", { name: "Filter jobs: All" }); + expect(allTab).toHaveAttribute("aria-selected", "true"); + }); + }); + }); + + describe("Disconnected wallet state", () => { + it("shows connect wallet message when no address", async () => { + mockUseWallet.mockReturnValue({ + address: null, + signTransaction: vi.fn(), + }); + + render(); + + expect(screen.getByText(/Connect your wallet to view your jobs/)).toBeInTheDocument(); + }); + + it("connect wallet message has proper ARIA attributes", async () => { + mockUseWallet.mockReturnValue({ + address: null, + signTransaction: vi.fn(), + }); + + render(); + + const message = screen.getByText(/Connect your wallet to view your jobs/); + expect(message).toHaveAttribute("role", "status"); + expect(message).toHaveAttribute("aria-live", "polite"); + }); + }); + + describe("Accessibility", () => { + it("maintains proper heading hierarchy", async () => { + setupFetch(mockJobsResponse([])); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { level: 1, name: "Job Dashboard" })).toBeInTheDocument(); + }); + }); + }); +}); + +function mockJob(overrides = {}) { + return { + id: "job-1234567890abcdef", + client: "GCLIENTADDRESS1234567890123456789012345678901234", + freelancer: "GFREELANCERADDRESS12345678901234567890123456", + arbiter: "GARBITERADDRESS12345678901234567890123456789012", + funded: true, + milestones: [ + { index: 0, amount: "250000000", status: "Pending" }, + { index: 1, amount: "500000000", status: "Delivered" }, + ], + tokenSymbol: "USDC", + tokenDecimals: 7, + ...overrides, + }; +} \ No newline at end of file diff --git a/__tests__/loading-skeleton-a11y.test.tsx b/__tests__/loading-skeleton-a11y.test.tsx new file mode 100644 index 0000000..1c84d7d --- /dev/null +++ b/__tests__/loading-skeleton-a11y.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import LoadingSkeleton from "@/app/components/LoadingSkeleton"; + +// =========================================================================== +// LoadingSkeleton — ARIA attribute compliance (issue #273) +// =========================================================================== + +describe("LoadingSkeleton — ARIA loading semantics", () => { + it("advertises the busy/loading state via aria-busy on the status node", () => { + render(); + expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true"); + }); + + it("keeps aria-live='polite' on the default (non-interactive) variant", () => { + render(); + expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); + }); + + it("does not apply aria-live to the interactive button variant", () => { + render(); + const button = screen.getByRole("button"); + expect(button).not.toHaveAttribute("aria-live"); + expect(button).toHaveAttribute("aria-busy", "true"); + }); + + it("exposes the accessible loading message as sr-only content", () => { + render(); + const message = screen.getByText("Loading job data…"); + expect(message).toHaveClass("sr-only"); + }); + + it("hides the visual skeleton placeholder from assistive technology", () => { + const { container } = render(); + const card = container.querySelector('[aria-hidden="true"]'); + expect(card).toBeInTheDocument(); + }); +}); + +describe("LoadingSkeleton — reduced motion compliance", () => { + it("disables the pulse animation under prefers-reduced-motion", () => { + render(); + expect(screen.getByRole("status")).toHaveClass("motion-reduce:animate-none"); + }); + + it("disables fade-in transitions under prefers-reduced-motion", () => { + render(); + expect(screen.getByRole("status")).toHaveClass("motion-reduce:transition-none"); + }); + + it("still renders the default, accessible pulse animation class", () => { + render(); + expect(screen.getByRole("status")).toHaveClass("animate-pulse"); + }); +}); + +describe("LoadingSkeleton — keyboard navigability", () => { + it("is focusable when interactive", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("tabindex", "0"); + }); + + it("is removed from the tab order when disabled", () => { + render(); + const button = screen.getByRole("button", { hidden: true }); + expect(button).toHaveAttribute("tabindex", "-1"); + }); + + it("responds to Enter and Space keys when interactive", () => { + const handleClick = vi.fn(); + render(); + const button = screen.getByRole("button"); + fireEvent.keyDown(button, { key: "Enter", code: "Enter" }); + fireEvent.keyDown(button, { key: " ", code: "Space" }); + expect(handleClick).toHaveBeenCalledTimes(2); + }); + + it("exposes a visible focus ring for the interactive variant", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toHaveClass("focus-visible:ring-2"); + expect(button).toHaveClass("focus-visible:ring-blue-500"); + }); +}); + +describe("LoadingSkeleton — interactive accessible name", () => { + it("accepts a custom aria-label for the interactive variant", () => { + render( + + ); + const button = screen.getByRole("button", { name: "Retry loading" }); + expect(button).toBeInTheDocument(); + }); + + it("falls back to the sr-only text as the accessible name when no label is set", () => { + render(); + // Accessible name is computed from the sr-only text content. + expect( + screen.getByRole("button", { name: "Loading job data…" }) + ).toBeInTheDocument(); + }); +}); + +describe("LoadingSkeleton — color contrast tokens", () => { + it("uses the dark surface background token for contrast", () => { + const { container } = render(); + const card = container.querySelector('[aria-hidden="true"]'); + expect(card).toHaveClass("bg-surface-card"); + }); +}); diff --git a/__tests__/loading-skeleton-validation.test.tsx b/__tests__/loading-skeleton-validation.test.tsx new file mode 100644 index 0000000..fc35a8b --- /dev/null +++ b/__tests__/loading-skeleton-validation.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import LoadingSkeleton from "@/app/components/LoadingSkeleton"; + +// =========================================================================== +// LoadingSkeleton — validation messages & alerts (issue #277) +// =========================================================================== + +describe("LoadingSkeleton — single validation error", () => { + it("renders an accessible alert when an error message is provided", () => { + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("Job budget is invalid")).toBeInTheDocument(); + }); + + it("renders the alert with a live assertive region", () => { + render(); + expect(screen.getByRole("alert")).toHaveAttribute("aria-live", "assertive"); + }); + + it("renders no alert when no error is provided", () => { + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("links the skeleton to the alerts via aria-describedby", () => { + render(); + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-describedby", "loading-skeleton-errors"); + expect(screen.getByTestId("loading-skeleton-errors")).toHaveAttribute( + "id", + "loading-skeleton-errors" + ); + }); + + it("does not set aria-describedby when there are no alerts", () => { + render(); + expect(screen.getByRole("status")).not.toHaveAttribute("aria-describedby"); + }); +}); + +describe("LoadingSkeleton — multiple validation errors (object keyed by field)", () => { + const errors = { + title: "Title is required", + budget: "Budget must be a positive number", + }; + + it("renders one alert per validation error", () => { + render(); + expect(screen.getAllByRole("alert")).toHaveLength(2); + }); + + it("prefixes each message with its field name", () => { + render(); + expect(screen.getByText("title:")).toBeInTheDocument(); + expect(screen.getByText("budget:")).toBeInTheDocument(); + expect(screen.getByText("Title is required")).toBeInTheDocument(); + expect(screen.getByText("Budget must be a positive number")).toBeInTheDocument(); + }); + + it("renders every field message value", () => { + render(); + expect(screen.getAllByRole("alert")).toHaveLength(2); + }); +}); + +describe("LoadingSkeleton — multiple validation errors (array form)", () => { + const errors = [ + { field: "escrowAgent", message: "Escrow agent is required" }, + { message: "Unexpected failure" }, + ]; + + it("renders one alert per array entry", () => { + render(); + expect(screen.getAllByRole("alert")).toHaveLength(2); + }); + + it("renders the field label only when a field is provided", () => { + render(); + expect(screen.getByText("escrowAgent:")).toBeInTheDocument(); + expect(screen.queryByText("Unexpected failure:")).not.toBeInTheDocument(); + expect(screen.getByText("Unexpected failure")).toBeInTheDocument(); + }); + + it("applies high-contrast error styling to alert content", () => { + render(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveClass("text-red-400"); + expect(alert).toHaveClass("bg-red-950/40"); + expect(alert).toHaveClass("border-red-800"); + }); +}); + +describe("LoadingSkeleton — empty collection of errors", () => { + it("renders no alerts for an empty object", () => { + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("renders no alerts for a null error prop", () => { + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); + +describe("LoadingSkeleton — alerts remain accessible in interactive mode", () => { + it("renders alerts alongside the interactive button", () => { + render(); + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/rabe_connector.test.ts b/__tests__/rabe_connector.test.ts index 46adcd0..689a77a 100644 --- a/__tests__/rabe_connector.test.ts +++ b/__tests__/rabe_connector.test.ts @@ -1,12 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { checkRabeNetworkMatch, + clearRabeAddressCache, + deserializeRabeAddressCache, formatConsoleWarningBlock, formatStackTrace, + loadRabeAddressCache, logRabeWarning, + RABE_CACHE_KEY, + RabeActiveAddressCache, RabeNetworkMismatchError, RabeTransactionTracker, rabeTracker, + saveRabeAddressCache, + serializeRabeAddressCache, + validateRabeAddressCache, warnOnRabeNetworkMismatch, } from "@/app/lib/rabe_connector"; @@ -248,3 +256,259 @@ describe("rabe_connector network mismatch checks", () => { expect(warnSpy).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Persistent address-cache tests +// --------------------------------------------------------------------------- + +/** A valid Stellar public key (56 chars, starts with G, Base32 A-Z2-7). */ +const VALID_ADDRESS = "GD4TI4BA2F6L3UE2SFNFEM5DBGIP2MYH7ID2KHDI2HF3YEEGCFB2OOAI"; +const VALID_NETWORK = "testnet" as const; + +function makeCache( + overrides: Partial = {} +): RabeActiveAddressCache { + return { + version: 1, + address: VALID_ADDRESS, + savedAt: Date.now(), + network: VALID_NETWORK, + ...overrides, + }; +} + +describe("rabe_connector active-address cache — validateRabeAddressCache", () => { + it("accepts a fully-valid cache object", () => { + expect(validateRabeAddressCache(makeCache())).toBe(true); + }); + + it("accepts mainnet as a valid network", () => { + expect(validateRabeAddressCache(makeCache({ network: "mainnet" }))).toBe( + true + ); + }); + + it("rejects null", () => { + expect(validateRabeAddressCache(null)).toBe(false); + }); + + it("rejects a non-object primitive", () => { + expect(validateRabeAddressCache("string")).toBe(false); + expect(validateRabeAddressCache(42)).toBe(false); + }); + + it("rejects an empty object", () => { + expect(validateRabeAddressCache({})).toBe(false); + }); + + it("rejects wrong version number", () => { + expect(validateRabeAddressCache({ ...makeCache(), version: 2 })).toBe( + false + ); + }); + + it("rejects a missing address field", () => { + const { address: _a, ...rest } = makeCache(); + expect(validateRabeAddressCache(rest)).toBe(false); + }); + + it("rejects an address that does not start with G", () => { + expect( + validateRabeAddressCache(makeCache({ address: "XD4TI4BA2F6L3UE2SFNFEM5DBGIP2MYH7ID2KHDI2HF3YEEGCFB2OOAI" })) + ).toBe(false); + }); + + it("rejects an address that is too short", () => { + expect( + validateRabeAddressCache(makeCache({ address: "GABC" })) + ).toBe(false); + }); + + it("rejects an address with invalid Base32 characters", () => { + // Contains '0' which is not in Base32 alphabet A-Z2-7 + expect( + validateRabeAddressCache(makeCache({ address: "G04TI4BA2F6L3UE2SFNFEM5DBGIP2MYH7ID2KHDI2HF3YEEGCFB2OOAI" })) + ).toBe(false); + }); + + it("rejects a non-numeric savedAt", () => { + expect( + validateRabeAddressCache({ ...makeCache(), savedAt: "now" }) + ).toBe(false); + }); + + it("rejects a zero savedAt", () => { + expect(validateRabeAddressCache(makeCache({ savedAt: 0 }))).toBe(false); + }); + + it("rejects a negative savedAt", () => { + expect(validateRabeAddressCache(makeCache({ savedAt: -1 }))).toBe(false); + }); + + it("rejects an unknown network string", () => { + expect( + validateRabeAddressCache({ ...makeCache(), network: "devnet" }) + ).toBe(false); + }); + + it("rejects when network is missing", () => { + const { network: _n, ...rest } = makeCache(); + expect(validateRabeAddressCache(rest)).toBe(false); + }); +}); + +describe("rabe_connector active-address cache — serialization round-trip", () => { + it("serializes to valid JSON and deserializes back to the original object", () => { + const original = makeCache(); + const json = serializeRabeAddressCache(original); + + expect(typeof json).toBe("string"); + expect(() => JSON.parse(json)).not.toThrow(); + + const restored = deserializeRabeAddressCache(json); + expect(restored).toEqual(original); + }); + + it("deserialization returns null for invalid JSON", () => { + expect(deserializeRabeAddressCache("{bad json")).toBeNull(); + }); + + it("deserialization returns null for JSON that fails validation", () => { + const badJson = JSON.stringify({ version: 99, address: "NOT_AN_ADDRESS" }); + expect(deserializeRabeAddressCache(badJson)).toBeNull(); + }); + + it("deserialization returns null for an empty string", () => { + expect(deserializeRabeAddressCache("")).toBeNull(); + }); + + it("deserialization returns null for a JSON null literal", () => { + expect(deserializeRabeAddressCache("null")).toBeNull(); + }); + + it("serialized output contains the address and network fields", () => { + const cache = makeCache({ network: "mainnet" }); + const json = serializeRabeAddressCache(cache); + expect(json).toContain(VALID_ADDRESS); + expect(json).toContain("mainnet"); + }); +}); + +describe("rabe_connector active-address cache — localStorage integration", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + localStorage.clear(); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + localStorage.clear(); + warnSpy.mockRestore(); + }); + + it("RABE_CACHE_KEY is a non-empty string constant", () => { + expect(typeof RABE_CACHE_KEY).toBe("string"); + expect(RABE_CACHE_KEY.length).toBeGreaterThan(0); + }); + + it("saveRabeAddressCache writes a valid entry to localStorage", () => { + saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK); + + const raw = localStorage.getItem(RABE_CACHE_KEY); + expect(raw).not.toBeNull(); + + const parsed = deserializeRabeAddressCache(raw!); + expect(parsed).not.toBeNull(); + expect(parsed!.address).toBe(VALID_ADDRESS); + expect(parsed!.network).toBe(VALID_NETWORK); + expect(parsed!.version).toBe(1); + expect(parsed!.savedAt).toBeGreaterThan(0); + }); + + it("loadRabeAddressCache returns null when the key is absent", () => { + expect(loadRabeAddressCache()).toBeNull(); + }); + + it("loadRabeAddressCache returns the saved cache after saveRabeAddressCache", () => { + saveRabeAddressCache(VALID_ADDRESS, "mainnet"); + + const loaded = loadRabeAddressCache(); + expect(loaded).not.toBeNull(); + expect(loaded!.address).toBe(VALID_ADDRESS); + expect(loaded!.network).toBe("mainnet"); + }); + + it("loadRabeAddressCache returns null for corrupt localStorage data", () => { + localStorage.setItem(RABE_CACHE_KEY, "!!!not-json!!!"); + expect(loadRabeAddressCache()).toBeNull(); + }); + + it("loadRabeAddressCache returns null when stored data fails validation", () => { + localStorage.setItem( + RABE_CACHE_KEY, + JSON.stringify({ version: 1, address: "bad", savedAt: -1, network: "testnet" }) + ); + expect(loadRabeAddressCache()).toBeNull(); + }); + + it("clearRabeAddressCache removes the entry from localStorage", () => { + saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK); + expect(localStorage.getItem(RABE_CACHE_KEY)).not.toBeNull(); + + clearRabeAddressCache(); + expect(localStorage.getItem(RABE_CACHE_KEY)).toBeNull(); + }); + + it("clearRabeAddressCache is a no-op when the key does not exist", () => { + expect(() => clearRabeAddressCache()).not.toThrow(); + expect(localStorage.getItem(RABE_CACHE_KEY)).toBeNull(); + }); + + it("second save overwrites the first entry", () => { + const SECOND_ADDRESS = "GCCS3QYX4XXUWSTYIDP2R6XQFSVWZVWOWNSWTA37ZAND5Z4Z5K7L5I6Y"; + saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK); + saveRabeAddressCache(SECOND_ADDRESS, "mainnet"); + + const loaded = loadRabeAddressCache(); + expect(loaded!.address).toBe(SECOND_ADDRESS); + expect(loaded!.network).toBe("mainnet"); + }); + + it("save → clear → load returns null", () => { + saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK); + clearRabeAddressCache(); + expect(loadRabeAddressCache()).toBeNull(); + }); + + it("savedAt timestamp is close to Date.now()", () => { + const before = Date.now(); + saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK); + const after = Date.now(); + + const loaded = loadRabeAddressCache(); + expect(loaded!.savedAt).toBeGreaterThanOrEqual(before); + expect(loaded!.savedAt).toBeLessThanOrEqual(after); + }); + + it("saveRabeAddressCache logs a warning when localStorage.setItem throws", () => { + const setItemSpy = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new DOMException("QuotaExceededError"); + }); + + // Should not throw itself + expect(() => + saveRabeAddressCache(VALID_ADDRESS, VALID_NETWORK) + ).not.toThrow(); + + // And should have logged a warning + expect(warnSpy).toHaveBeenCalledTimes(1); + const logged = String(warnSpy.mock.calls[0][0]); + expect(logged).toContain("[rabe_connector]"); + expect(logged).toContain("CACHE WRITE FAILED"); + + setItemSpy.mockRestore(); + }); +}); diff --git a/__tests__/wallet_disconnect_handler.component.test.ts b/__tests__/wallet_disconnect_handler.component.test.ts index 5fb87fc..8bbfed7 100644 --- a/__tests__/wallet_disconnect_handler.component.test.ts +++ b/__tests__/wallet_disconnect_handler.component.test.ts @@ -3,6 +3,8 @@ import { detectWalletExtensionById, checkWalletAvailabilityById, disconnectWalletWithCheck, + isWalletDisconnectUserRejected, + WalletDisconnectUserRejectedError, type WalletDisconnectResult, } from "@/app/lib/wallet_disconnect_handler"; @@ -855,3 +857,325 @@ describe("wallet_disconnect_handler checkWalletAvailabilityById detailed scenari expect(errorSpy).toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// User signature rejection handling (#235) +// --------------------------------------------------------------------------- + +describe("wallet_disconnect_handler user signature rejection handling", () => { + let warnSpy: ReturnType; + let errorSpy: ReturnType; + type DisconnectToastHandler = ( + message: string, + type: "warning" | "error" | "info" | "success", + ) => void; + + let toastSpy: ReturnType>; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + toastSpy = vi.fn(); + }); + + afterEach(() => { + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + // ------------------------------------------------------------------------- + // isWalletDisconnectUserRejected detection function + // ------------------------------------------------------------------------- + + describe("isWalletDisconnectUserRejected detection", () => { + it("returns true for WalletDisconnectUserRejectedError instance", () => { + const error = new WalletDisconnectUserRejectedError(); + expect(isWalletDisconnectUserRejected(error)).toBe(true); + }); + + it("returns true for error with 'user rejected' message", () => { + const error = new Error("user rejected transaction"); + expect(isWalletDisconnectUserRejected(error)).toBe(true); + }); + + it("returns true for error with 'user declined' message", () => { + const error = new Error("user declined the request"); + expect(isWalletDisconnectUserRejected(error)).toBe(true); + }); + + it("returns true for error with 'request rejected' message", () => { + const error = new Error("request rejected by wallet"); + expect(isWalletDisconnectUserRejected(error)).toBe(true); + }); + + it("returns true for error with 'denied by the user' message", () => { + const error = new Error("denied by the user"); + expect(isWalletDisconnectUserRejected(error)).toBe(true); + }); + + it("returns true for error with 'rejected by user' message", () => { + const error = new Error("rejected by user"); + expect(isWalletDisconnectUserRejected(error)).toBe(true); + }); + + it("returns true for error with 'canceled by user' message", () => { + const error = new Error("canceled by user"); + expect(isWalletDisconnectUserRejected(error)).toBe(true); + }); + + it("returns true for error with 'cancelled by user' message", () => { + const error = new Error("cancelled by user"); + expect(isWalletDisconnectUserRejected(error)).toBe(true); + }); + + it("returns false for non-Error objects", () => { + expect(isWalletDisconnectUserRejected("user rejected")).toBe(false); + expect(isWalletDisconnectUserRejected(null)).toBe(false); + expect(isWalletDisconnectUserRejected(undefined)).toBe(false); + expect(isWalletDisconnectUserRejected(123)).toBe(false); + }); + + it("returns false for errors without rejection keywords", () => { + const error = new Error("network timeout"); + expect(isWalletDisconnectUserRejected(error)).toBe(false); + }); + + it("is case-insensitive for rejection keywords", () => { + const error = new Error("USER REJECTED TRANSACTION"); + expect(isWalletDisconnectUserRejected(error)).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // disconnectWalletWithCheck with user rejection + // ------------------------------------------------------------------------- + + describe("disconnectWalletWithCheck with user rejection", () => { + it("handles user rejection with toast notification", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("user rejected transaction"); + }); + + const result = await disconnectWalletWithCheck( + "freighter", + disconnectFn, + () => true, + undefined, + undefined, + toastSpy, + ); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + expect(result.fallbackInstructions).toBeNull(); + expect(result.installUrl).toBeNull(); + expect(toastSpy).toHaveBeenCalledTimes(1); + expect(toastSpy).toHaveBeenCalledWith( + "Disconnect cancelled — you rejected the request in your wallet.", + "warning", + ); + expect(warnSpy).toHaveBeenCalled(); + const logged = String(warnSpy.mock.calls[0][0]); + expect(logged).toContain("[wallet_disconnect_handler]"); + expect(logged).toContain("DISCONNECT REJECTED"); + expect(logged).toContain("freighter"); + }); + + it("handles user rejection without toast handler", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("user declined"); + }); + + const result = await disconnectWalletWithCheck( + "albedo", + disconnectFn, + () => true, + ); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + expect(toastSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalled(); + }); + + it("handles WalletDisconnectUserRejectedError instance", async () => { + const disconnectFn = vi.fn(async () => { + throw new WalletDisconnectUserRejectedError(); + }); + + const result = await disconnectWalletWithCheck( + "xbull", + disconnectFn, + () => true, + undefined, + undefined, + toastSpy, + ); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + expect(toastSpy).toHaveBeenCalledWith( + "Disconnect cancelled — you rejected the request in your wallet.", + "warning", + ); + }); + + it("removes active key on user rejection", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("user rejected transaction"); + }); + + // Register an active key first + const { registerActiveWalletKey } = await import("@/app/lib/wallet_disconnect_handler"); + registerActiveWalletKey("freighter", "GTEST123"); + + const result = await disconnectWalletWithCheck( + "freighter", + disconnectFn, + () => true, + undefined, + undefined, + toastSpy, + ); + + expect(result.success).toBe(true); + // Verify the key was removed + const { walletActiveKeysStore } = await import("@/app/lib/wallet_disconnect_handler"); + expect(walletActiveKeysStore.hasActiveKey("freighter")).toBe(false); + }); + + it("handles 'request rejected' error message", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("request rejected by wallet"); + }); + + const result = await disconnectWalletWithCheck( + "hana", + disconnectFn, + () => true, + undefined, + undefined, + toastSpy, + ); + + expect(result.success).toBe(true); + expect(toastSpy).toHaveBeenCalled(); + }); + + it("handles 'denied by the user' error message", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("denied by the user"); + }); + + const result = await disconnectWalletWithCheck( + "freighter", + disconnectFn, + () => true, + undefined, + undefined, + toastSpy, + ); + + expect(result.success).toBe(true); + expect(toastSpy).toHaveBeenCalled(); + }); + + it("handles 'cancelled by user' error message", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("cancelled by user"); + }); + + const result = await disconnectWalletWithCheck( + "albedo", + disconnectFn, + () => true, + undefined, + undefined, + toastSpy, + ); + + expect(result.success).toBe(true); + expect(toastSpy).toHaveBeenCalled(); + }); + + it("does not treat non-rejection errors as user rejection", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("network timeout"); + }); + + const result = await disconnectWalletWithCheck( + "freighter", + disconnectFn, + () => true, + undefined, + undefined, + toastSpy, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("network timeout"); + expect(toastSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("does not treat generic errors as user rejection", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("wallet disconnected unexpectedly"); + }); + + const result = await disconnectWalletWithCheck( + "xbull", + disconnectFn, + () => true, + undefined, + undefined, + toastSpy, + ); + + expect(result.success).toBe(false); + expect(result.error).toBe("wallet disconnected unexpectedly"); + expect(toastSpy).not.toHaveBeenCalled(); + }); + + it("logs warning when user rejects disconnect", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("user rejected transaction"); + }); + + await disconnectWalletWithCheck( + "freighter", + disconnectFn, + () => true, + undefined, + undefined, + toastSpy, + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const logged = String(warnSpy.mock.calls[0][0]); + expect(logged).toContain("[wallet_disconnect_handler]"); + expect(logged).toContain("DISCONNECT REJECTED"); + expect(logged).toContain("freighter"); + }); + + it("handles user rejection with pending transaction context", async () => { + const disconnectFn = vi.fn(async () => { + throw new Error("user rejected transaction"); + }); + + const result = await disconnectWalletWithCheck( + "freighter", + disconnectFn, + () => true, + undefined, + { txId: "tx_123", status: "signing", context: "milestone release" }, + toastSpy, + ); + + expect(result.success).toBe(true); + expect(toastSpy).toHaveBeenCalled(); + // Should also log the pending transaction warning + expect(warnSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/DashboardList.stories.tsx b/app/components/DashboardList.stories.tsx new file mode 100644 index 0000000..6e7c97b --- /dev/null +++ b/app/components/DashboardList.stories.tsx @@ -0,0 +1,498 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import LoadingSkeleton from "./LoadingSkeleton"; +import EmptyStateCard from "./EmptyStateCard"; +import MilestoneCard from "./MilestoneCard"; + +// Mock job data for stories +const mockJob = (overrides = {}) => ({ + id: "job-1234567890abcdef", + client: "GCLIENTADDRESS1234567890123456789012345678901234", + freelancer: "GFREELANCERADDRESS12345678901234567890123456", + arbiter: "GARBITERADDRESS12345678901234567890123456789012", + funded: true, + milestones: [ + { index: 0, amount: "250000000", status: "Pending" }, + { index: 1, amount: "500000000", status: "Delivered" }, + ], + tokenSymbol: "USDC", + tokenDecimals: 7, + ...overrides, +}); + +const mockJobs = [ + mockJob({ id: "job-1", funded: true }), + mockJob({ id: "job-2", funded: false }), + mockJob({ id: "job-3", funded: true, milestones: [] }), +]; + +const mockActions = { + partialReleaseState: { phase: "idle" as const, error: null, txHash: null }, + claimAutoReleaseState: { phase: "idle" as const, error: null, txHash: null }, + isPartialReleasePending: false, + isClaimAutoReleasePending: false, + onMarkDelivered: fn(), + onApprove: fn(), + onDispute: fn(), + onPartialRelease: fn(), + onClaimAutoRelease: fn(), + onResolveDispute: fn(), +}; + +// Mock components for story isolation +const MockDashboardList = ({ jobs = mockJobs, loading = false, error = null, expandedJobId = null, onExpand = fn() }) => { + const mockAddress = "GCLIENTADDRESS1234567890123456789012345678901234"; + + if (loading) return ; + + if (error) { + return ( +
+

Error loading jobs

+

{error}

+
+ ); + } + + if (!jobs.length) { + return ( + + ); + } + + return ( +
+
+ {jobs.map((job, index) => { + const isExpanded = expandedJobId === job.id; + const roleBadges = [ + mockAddress === job.client ? "Client" : null, + mockAddress === job.freelancer ? "Freelancer" : null, + mockAddress === job.arbiter ? "Arbiter" : null, + ].filter(Boolean) as string[]; + + return ( +
+ + + {isExpanded && ( +
+ {job.milestones?.length ? ( + job.milestones.map((m) => ( + + )) + ) : ( + + )} +
+ )} +
+ ); + })} +
+
+ ); +}; + +const meta = { + title: "Components/DashboardList", + component: MockDashboardList, + tags: ["autodocs"], + parameters: { + layout: "padded", + backgrounds: { + default: "dark", + values: [ + { name: "dark", value: "#030712" }, + { name: "light", value: "#ffffff" }, + ], + }, + }, + argTypes: { + loading: { control: "boolean", description: "Show loading skeleton" }, + error: { control: "text", description: "Error message to display" }, + expandedJobId: { control: "text", description: "ID of expanded job" }, + onExpand: { action: "expanded" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// 1. Loading state - skeleton shown +// --------------------------------------------------------------------------- +export const Loading: Story = { + name: "Loading — skeleton", + args: { + loading: true, + jobs: [], + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 2. Error state +// --------------------------------------------------------------------------- +export const Error: Story = { + name: "Error — backend failure", + args: { + loading: false, + jobs: [], + error: "Failed to fetch jobs. Please try again later.", + }, +}; + +// --------------------------------------------------------------------------- +// 3. Empty state - no jobs +// --------------------------------------------------------------------------- +export const Empty: Story = { + name: "Empty — no jobs found", + args: { + loading: false, + jobs: [], + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 4. Single job - funded +// --------------------------------------------------------------------------- +export const SingleJobFunded: Story = { + name: "Single Job — funded", + args: { + loading: false, + jobs: [mockJob({ id: "job-funded-1", funded: true })], + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 5. Single job - not funded +// --------------------------------------------------------------------------- +export const SingleJobNotFunded: Story = { + name: "Single Job — not funded", + args: { + loading: false, + jobs: [mockJob({ id: "job-unfunded-1", funded: false })], + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 6. Multiple jobs - mixed states +// --------------------------------------------------------------------------- +export const MultipleJobs: Story = { + name: "Multiple Jobs — mixed funded/unfunded", + args: { + loading: false, + jobs: [ + mockJob({ id: "job-1-funded", funded: true }), + mockJob({ id: "job-2-unfunded", funded: false }), + mockJob({ id: "job-3-funded", funded: true }), + ], + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 7. Job with milestones - expanded +// --------------------------------------------------------------------------- +export const ExpandedWithMilestones: Story = { + name: "Expanded — job with milestones", + args: { + loading: false, + jobs: [ + mockJob({ id: "job-with-milestones", funded: true, milestones: [ + { index: 0, amount: "250000000", status: "Pending" }, + { index: 1, amount: "500000000", status: "Delivered" }, + { index: 2, amount: "750000000", status: "Released" }, + ]}), + ], + error: null, + expandedJobId: "job-with-milestones", + }, +}; + +// --------------------------------------------------------------------------- +// 8. Job with no milestones - expanded +// --------------------------------------------------------------------------- +export const ExpandedNoMilestones: Story = { + name: "Expanded — job with no milestones", + args: { + loading: false, + jobs: [mockJob({ id: "job-no-milestones", funded: true, milestones: [] })], + error: null, + expandedJobId: "job-no-milestones", + }, +}; + +// --------------------------------------------------------------------------- +// 9. Role badges - client view +// --------------------------------------------------------------------------- +export const ClientView: Story = { + name: "Role — client view", + args: { + loading: false, + jobs: [mockJob({ + id: "job-client-view", + funded: true, + client: "GCURRENTUSER1234567890123456789012345678901234", + freelancer: "GFREELANCERADDRESS12345678901234567890123456", + })], + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 10. Role badges - freelancer view +// --------------------------------------------------------------------------- +export const FreelancerView: Story = { + name: "Role — freelancer view", + args: { + loading: false, + jobs: [mockJob({ + id: "job-freelancer-view", + funded: true, + client: "GCLIENTADDRESS1234567890123456789012345678901234", + freelancer: "GCURRENTUSER1234567890123456789012345678901234", + })], + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 11. Role badges - arbiter view +// --------------------------------------------------------------------------- +export const ArbiterView: Story = { + name: "Role — arbiter view", + args: { + loading: false, + jobs: [mockJob({ + id: "job-arbiter-view", + funded: true, + client: "GCLIENTADDRESS1234567890123456789012345678901234", + freelancer: "GFREELANCERADDRESS12345678901234567890123456", + arbiter: "GCURRENTUSER1234567890123456789012345678901234", + })], + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 12. Role badges - multiple roles (client + freelancer) +// --------------------------------------------------------------------------- +export const MultiRoleView: Story = { + name: "Role — multiple roles (client + freelancer)", + args: { + loading: false, + jobs: [mockJob({ + id: "job-multi-role", + funded: true, + client: "GCURRENTUSER1234567890123456789012345678901234", + freelancer: "GCURRENTUSER1234567890123456789012345678901234", + arbiter: "GARBITERADDRESS12345678901234567890123456789012", + })], + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 13. Multiple jobs expanded - first expanded +// --------------------------------------------------------------------------- +export const FirstJobExpanded: Story = { + name: "Multiple — first job expanded", + args: { + loading: false, + jobs: [ + mockJob({ id: "job-1-expanded", funded: true }), + mockJob({ id: "job-2-collapsed", funded: false }), + mockJob({ id: "job-3-collapsed", funded: true }), + ], + error: null, + expandedJobId: "job-1-expanded", + }, +}; + +// --------------------------------------------------------------------------- +// 14. Pagination - many jobs +// --------------------------------------------------------------------------- +export const ManyJobs: Story = { + name: "Many Jobs — pagination scenario", + args: { + loading: false, + jobs: Array.from({ length: 12 }, (_, i) => + mockJob({ id: `job-${i + 1}`, funded: i % 2 === 0 }) + ), + error: null, + }, +}; + +// --------------------------------------------------------------------------- +// 15. Large milestone count - layout stress +// --------------------------------------------------------------------------- +export const LargeMilestoneCount: Story = { + name: "Stress — many milestones", + args: { + loading: false, + jobs: [mockJob({ + id: "job-many-milestones", + funded: true, + milestones: Array.from({ length: 10 }, (_, i) => ({ + index: i, + amount: String(100000000 * (i + 1)), + status: i % 3 === 0 ? "Pending" : i % 3 === 1 ? "Delivered" : "Released", + })), + })], + error: null, + expandedJobId: "job-many-milestones", + }, +}; + +// --------------------------------------------------------------------------- +// 16. All states overview +// --------------------------------------------------------------------------- +export const AllStates: Story = { + name: "All States — overview", + render: () => ( +
+
+

Loading

+ +
+
+

Error

+ +
+
+

Empty

+ +
+
+

Single Funded

+ +
+
+

Multiple Mixed

+ +
+
+ ), +}; + +// --------------------------------------------------------------------------- +// 17. Interactive - expanded job +// --------------------------------------------------------------------------- +export const InteractiveExpanded: Story = { + name: "Interactive — expand/collapse", + args: { + loading: false, + jobs: [ + mockJob({ id: "interactive-1", funded: true }), + mockJob({ id: "interactive-2", funded: false }), + ], + expandedJobId: "interactive-1", + }, + parameters: { + pseudo: { hover: true }, + }, +}; + +// --------------------------------------------------------------------------- +// 18. Job with auto-release countdown (delivered milestone) +// --------------------------------------------------------------------------- +export const WithAutoReleaseCountdown: Story = { + name: "Delivered — auto-release countdown", + args: { + loading: false, + jobs: [mockJob({ + id: "job-auto-release", + funded: true, + milestones: [ + { index: 0, amount: "250000000", status: "Pending" }, + { index: 1, amount: "500000000", status: "Delivered" }, + ], + })], + error: null, + expandedJobId: "job-auto-release", + }, +}; + +// --------------------------------------------------------------------------- +// 19. Disputed milestone +// --------------------------------------------------------------------------- +export const DisputedMilestone: Story = { + name: "Disputed — milestone in dispute", + args: { + loading: false, + jobs: [mockJob({ + id: "job-disputed", + funded: true, + milestones: [ + { index: 0, amount: "250000000", status: "Pending" }, + { index: 1, amount: "500000000", status: "Disputed" }, + ], + })], + error: null, + expandedJobId: "job-disputed", + }, +}; + +// --------------------------------------------------------------------------- +// 20. Partially released milestone +// --------------------------------------------------------------------------- +export const PartiallyReleased: Story = { + name: "Partially Released — 60% released", + args: { + loading: false, + jobs: [mockJob({ + id: "job-partial", + funded: true, + milestones: [ + { index: 0, amount: "1000000000", status: "PartiallyReleased", releasedAmount: "600000000" }, + ], + })], + error: null, + expandedJobId: "job-partial", + }, +}; \ No newline at end of file diff --git a/app/components/LoadingSkeleton.tsx b/app/components/LoadingSkeleton.tsx index 26ed027..fd37b0d 100644 --- a/app/components/LoadingSkeleton.tsx +++ b/app/components/LoadingSkeleton.tsx @@ -1,3 +1,10 @@ +interface ValidationError { + /** Human-readable error message. */ + message: string; + /** Optional field/input this error belongs to. */ + field?: string; +} + interface LoadingSkeletonProps { className?: string; /** @@ -9,6 +16,28 @@ interface LoadingSkeletonProps { onClick?: () => void; "aria-label"?: string; tabIndex?: number; + /** + * A single validation error to surface as an accessible alert + * (role="alert", aria-live="assertive"). + */ + error?: string | null; + /** + * Multiple validation errors keyed by field, each rendered as an + * accessible alert when present. + */ + validationErrors?: ValidationError[] | Record | null; +} + +/** + * Returns true for plain objects (including records and single-element + * arrays), used to normalize the flexible `validationErrors` prop. + */ +function isRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) + ); } /** @@ -25,6 +54,8 @@ export default function LoadingSkeleton({ onClick, "aria-label": ariaLabel, tabIndex, + error = null, + validationErrors = null, }: LoadingSkeletonProps = {}) { const interactiveClasses = interactive ? "cursor-pointer transition-all duration-200 ease-in-out hover:border-gray-700 hover:bg-gray-900/90 hover:shadow-lg hover:shadow-gray-950/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950" @@ -34,12 +65,27 @@ export default function LoadingSkeleton({ ? "opacity-50 cursor-not-allowed pointer-events-none" : ""; + // Normalize the validation errors into a stable list of { message, field }. + const renderedErrors: ValidationError[] = (() => { + if (Array.isArray(validationErrors) || isRecord(validationErrors)) { + return Object.entries(validationErrors).map(([key, value]) => { + if (typeof value === "string") { + return { field: key, message: value }; + } + return { field: value?.field ?? key, message: value?.message ?? "" }; + }); + } + return error ? [{ message: error }] : []; + })(); + return (
0 ? "loading-skeleton-errors" : undefined} aria-disabled={disabled ? "true" : undefined} tabIndex={interactive ? (disabled ? -1 : tabIndex ?? 0) : undefined} onClick={!disabled ? onClick : undefined} @@ -56,9 +102,31 @@ export default function LoadingSkeleton({ data-testid="loading-skeleton" > Loading job data… + {renderedErrors.length > 0 && ( +
+ {renderedErrors.map((validationError, index) => ( +

+ {validationError.field && ( + {validationError.field}: + )} + {validationError.message} +

+ ))} +
+ )} {/* Caps the skeleton on small viewports so it scrolls internally - instead of pushing the surrounding controls off-screen, and - keeps overscroll inside the wrapper rather than the page. */} + instead of pushing the surrounding controls off-screen, and + keeps overscroll inside the wrapper rather than the page. */}
void>(); + +function subscribe(cb: () => void) { + listeners.add(cb); + return () => listeners.delete(cb); +} + +function notifyListeners() { + listeners.forEach((cb) => cb()); +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface ThemeContextValue { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + // useSyncExternalStore avoids the react-hooks/set-state-in-effect lint rule. + // getServerSnapshot returns "dark" so SSR output is deterministic. + const theme = useSyncExternalStore(subscribe, readTheme, () => "dark" as Theme); + + const toggleTheme = useCallback(() => { + const next: Theme = readTheme() === "dark" ? "light" : "dark"; + applyTheme(next); + notifyListeners(); + }, []); + + return ( + + {children} + + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within a ThemeProvider"); + return ctx; +} diff --git a/app/lib/rabe_connector.ts b/app/lib/rabe_connector.ts index 4010f13..3615fdd 100644 --- a/app/lib/rabe_connector.ts +++ b/app/lib/rabe_connector.ts @@ -401,3 +401,160 @@ export async function runRabeSign( throw err; } } + + +// --------------------------------------------------------------------------- +// Secure persistent caching for active Rabe wallet addresses +// --------------------------------------------------------------------------- + +/** localStorage key under which the serialized address cache is stored. */ +export const RABE_CACHE_KEY = "rabe_active_address_cache"; + +/** + * Shape of the cache entry persisted to localStorage. + * Using a versioned envelope makes future migrations explicit. + */ +export interface RabeActiveAddressCache { + /** Schema version — increment when the shape changes. */ + version: 1; + /** The Stellar public key of the connected wallet (G… address). */ + address: string; + /** Unix-ms timestamp of when the entry was last written. */ + savedAt: number; + /** Network the wallet was connected to when the address was cached. */ + network: RabeNetwork; +} + +// ---- Validation helpers --------------------------------------------------- + +/** + * Returns true when `value` looks like a valid Stellar public key. + * Public keys are 56 characters, start with "G", and are Base32 (A-Z 2-7). + */ +function isValidStellarAddress(value: string): boolean { + return /^G[A-Z2-7]{55}$/.test(value); +} + +/** + * Returns true when `value` is one of the known {@link RabeNetwork} literals. + */ +function isValidNetwork(value: unknown): value is RabeNetwork { + return value === "mainnet" || value === "testnet"; +} + +// ---- Public cache API ----------------------------------------------------- + +/** + * Validates that `raw` conforms to the {@link RabeActiveAddressCache} shape. + * Returns `true` when every required field passes its type and value check. + */ +export function validateRabeAddressCache( + raw: unknown +): raw is RabeActiveAddressCache { + if (!raw || typeof raw !== "object") return false; + + const candidate = raw as Record; + + if (candidate.version !== 1) return false; + if (typeof candidate.address !== "string") return false; + if (!isValidStellarAddress(candidate.address)) return false; + if (typeof candidate.savedAt !== "number") return false; + if (!Number.isFinite(candidate.savedAt) || candidate.savedAt <= 0) + return false; + if (!isValidNetwork(candidate.network)) return false; + + return true; +} + +/** + * Serializes an {@link RabeActiveAddressCache} entry to a JSON string. + * Exposed for testing; normal callers should use {@link saveRabeAddressCache}. + */ +export function serializeRabeAddressCache( + cache: RabeActiveAddressCache +): string { + return JSON.stringify(cache); +} + +/** + * Parses a JSON string and validates it as an {@link RabeActiveAddressCache}. + * Returns `null` when parsing fails or the data does not pass validation. + */ +export function deserializeRabeAddressCache( + raw: string +): RabeActiveAddressCache | null { + try { + const parsed: unknown = JSON.parse(raw); + return validateRabeAddressCache(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Writes the active-address cache to localStorage. + * + * Safe to call in SSR contexts — the write is skipped when `window` is + * unavailable so Next.js server renders do not throw. + */ +export function saveRabeAddressCache( + address: string, + network: RabeNetwork +): void { + if (typeof window === "undefined") return; + + const cache: RabeActiveAddressCache = { + version: 1, + address, + savedAt: Date.now(), + network, + }; + + try { + localStorage.setItem(RABE_CACHE_KEY, serializeRabeAddressCache(cache)); + } catch (err) { + // localStorage may be unavailable (private mode quota exceeded, etc.) + logRabeWarning( + "CACHE WRITE FAILED", + "Could not persist active address cache to localStorage.", + { err } + ); + } +} + +/** + * Reads and validates the active-address cache from localStorage. + * + * Returns `null` when: + * - the code is running server-side, + * - the key is absent, + * - the stored value is corrupt or fails validation. + */ +export function loadRabeAddressCache(): RabeActiveAddressCache | null { + if (typeof window === "undefined") return null; + + try { + const raw = localStorage.getItem(RABE_CACHE_KEY); + if (raw === null) return null; + return deserializeRabeAddressCache(raw); + } catch { + // Gracefully handle any unexpected storage errors. + return null; + } +} + +/** + * Removes the active-address cache entry from localStorage. + * + * Call this on wallet disconnect or when the cached address can no longer + * be verified as active. + */ +export function clearRabeAddressCache(): void { + if (typeof window === "undefined") return; + + try { + localStorage.removeItem(RABE_CACHE_KEY); + } catch { + // Ignore — if we can't remove it, there is nothing more to do. + } +} diff --git a/app/lib/wallet_disconnect_handler.ts b/app/lib/wallet_disconnect_handler.ts index dcf5d5d..55766e2 100644 --- a/app/lib/wallet_disconnect_handler.ts +++ b/app/lib/wallet_disconnect_handler.ts @@ -469,10 +469,15 @@ export interface PendingTxSnapshot { * via `pendingTx` so it is logged as a console.warn for post-mortem * debugging — the handler does not change control flow based on it. * + * User signature rejections are caught gracefully: a warning toast is shown + * and the function returns success (since the user intentionally cancelled). + * * @param walletId - The wallet provider ID. * @param disconnectFn - The actual disconnect function (e.g. StellarWalletsKit.disconnect()). * @param detector - Optional availability-detector override for tests. + * @param options - Optional timeout and cleanup options. * @param pendingTx - Optional snapshot of an in-flight transaction at disconnect time. + * @param showToast - Optional toast handler for user rejection warnings. */ export async function disconnectWalletWithCheck( walletId: string, @@ -480,6 +485,7 @@ export async function disconnectWalletWithCheck( detector?: () => boolean, options?: WalletDisconnectTimeoutOptions, pendingTx?: PendingTxSnapshot, + showToast?: (message: string, type: "warning" | "error" | "info" | "success") => void, ): Promise { // Warn immediately if a transaction was in flight when disconnect was called. if (pendingTx && (pendingTx.txId ?? pendingTx.status ?? pendingTx.context)) { @@ -527,6 +533,28 @@ export async function disconnectWalletWithCheck( installUrl: null, }; } catch (err) { + // Handle user rejection gracefully - show warning toast and return success + if (isWalletDisconnectUserRejected(err)) { + console.warn( + `${LOG_PREFIX} DISCONNECT REJECTED by user for "${walletId}":`, + err, + ); + // Remove from active keys store even on user rejection + walletActiveKeysStore.removeActiveKey(walletId); + if (showToast) { + showToast( + "Disconnect cancelled — you rejected the request in your wallet.", + "warning", + ); + } + return { + success: true, + error: null, + fallbackInstructions: null, + installUrl: null, + }; + } + const message = err instanceof Error ? err.message @@ -748,6 +776,37 @@ export function warnOnDisconnectNetworkMismatch( return state; } +// ============================================================= +// User signature rejection handling (#235) +// ============================================================= + +export class WalletDisconnectUserRejectedError extends Error { + constructor(message = "user rejected transaction") { + super(message); + this.name = "WalletDisconnectUserRejectedError"; + } +} + +/** + * Detects "user rejected the signature request" style errors from wallet + * disconnect operations. Mirrors the pattern used in albedo_connector and + * freighter_connector for consistency across the codebase. + */ +export function isWalletDisconnectUserRejected(err: unknown): boolean { + if (err instanceof WalletDisconnectUserRejectedError) return true; + if (!(err instanceof Error)) return false; + const message = err.message.toLowerCase(); + return ( + message.includes("user rejected") || + message.includes("user declined") || + message.includes("request rejected") || + message.includes("denied by the user") || + message.includes("rejected by user") || + message.includes("canceled by user") || + message.includes("cancelled by user") + ); +} + // ============================================================= // Gas estimation / simulation fee warnings (#240) // ============================================================= diff --git a/vitest.setup.ts b/vitest.setup.ts index 179b84a..46e17df 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,5 +1,25 @@ import "@testing-library/jest-dom/vitest"; +// jsdom doesn't implement matchMedia. Provide a minimal stub so any code +// that calls window.matchMedia("...").matches doesn't throw. +if (typeof window !== "undefined" && typeof window.matchMedia === "undefined") { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList, + }); +} + // jsdom environment: normalise the web-storage globals across Node versions. // // Newer Node releases ship an experimental `localStorage` global that reads as