Skip to content
Open
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
73 changes: 73 additions & 0 deletions __tests__/wallet_disconnect_handler_availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
detectWalletExtensionById,
checkWalletAvailabilityById,
disconnectWalletWithCheck,
runWalletDisconnectWithTimeout,
WalletDisconnectTimeoutError,
} from "@/app/lib/wallet_disconnect_handler";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -309,3 +311,74 @@ describe("wallet_disconnect_handler disconnectWalletWithCheck (#task-4)", () =>
expect(logged).toContain("not installed");
});
});

describe("wallet_disconnect_handler timeout bounds", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("aborts a stalled operation and clears payload and listeners", async () => {
let signal: AbortSignal | undefined;
const payload = new Uint8Array([1, 2, 3]);
const cleanup = vi.fn();
const operation = runWalletDisconnectWithTimeout(
(operationSignal) => {
signal = operationSignal;
return new Promise<never>(() => {});
},
{ timeoutMs: 100, request: { payload }, cleanup },
);

await vi.advanceTimersByTimeAsync(100);

await expect(operation).rejects.toBeInstanceOf(WalletDisconnectTimeoutError);
expect(signal?.aborted).toBe(true);
expect([...payload]).toEqual([0, 0, 0]);
expect(cleanup).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});

it("clears payload, listeners, and the timer when the operation succeeds", async () => {
const payload = new Uint8Array([4, 5]);
const cleanup = vi.fn();

await expect(
runWalletDisconnectWithTimeout(
async () => "disconnected",
{ timeoutMs: 100, request: { payload }, cleanup },
),
).resolves.toBe("disconnected");

expect([...payload]).toEqual([0, 0]);
expect(cleanup).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});

it("returns a timeout error from disconnectWalletWithCheck and aborts its provider", async () => {
let signal: AbortSignal | undefined;
const payload = new Uint8Array([9]);
const cleanup = vi.fn();
const resultPromise = disconnectWalletWithCheck(
"freighter",
(operationSignal) => {
signal = operationSignal;
return new Promise<void>(() => {});
},
() => true,
{ timeoutMs: 50, request: { payload }, cleanup },
);

await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;

expect(result.success).toBe(false);
expect(result.error).toMatch(/timed out after 50ms/);
expect(signal?.aborted).toBe(true);
expect([...payload]).toEqual([0]);
expect(cleanup).toHaveBeenCalledOnce();
});
});
56 changes: 55 additions & 1 deletion __tests__/wallet_selector_modal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import WalletSelectorModal, {
detectAnyWalletExtension,
Expand Down Expand Up @@ -206,6 +206,60 @@ describe("WalletSelectorModal wallet availability (#103)", () => {
// Task 4 — Graceful handling of user signature rejection exceptions
// ---------------------------------------------------------------------------

describe("WalletSelectorModal design tokens", () => {
it("uses semantic design token classes for modal shell and alert surfaces", () => {
render(<WalletSelectorModal isOpen={true} onClose={vi.fn()} />);

const backdrop = screen.getByTestId("wallet-selector-modal");
expect(backdrop).toHaveClass("bg-surface-page/80");

const content = screen.getByTestId("wallet-selector-modal-content");
expect(content).toHaveClass(
"bg-surface-card",
"border",
"border-border-subtle",
"text-text-primary"
);

const title = screen.getByText("Select Wallet");
expect(title).toHaveClass("text-text-primary");

const warning = screen.getByTestId("wallet-selector-availability-warning");
expect(warning).toHaveClass(
"bg-warning-soft/10",
"border-warning-soft/40",
"text-warning-soft"
);

const errorMessage = screen.getByTestId("wallet-selector-error-message");
expect(errorMessage).toHaveClass(
"bg-danger/20",
"border-danger/40",
"text-danger-soft"
);
});

it("uses design-token classes for wallet rows and status badges", () => {
render(
<WalletSelectorModal
isOpen={true}
onClose={vi.fn()}
selectedWalletId="freighter"
/>
);

const selectedOption = screen.getByTestId("wallet-selector-option-freighter");
expect(selectedOption).toHaveClass(
"border-accent-soft",
"bg-accent/10",
"text-text-primary"
);

const connectedBadge = screen.getByTestId("wallet-selector-connected-badge");
expect(connectedBadge).toHaveClass("text-success-soft");
});
});

describe("WalletSelectorModal signature rejection handling (#105)", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
38 changes: 19 additions & 19 deletions app/components/WalletSelectorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,22 +224,22 @@ export default function WalletSelectorModal({
data-testid="wallet-selector-modal"
role="dialog"
aria-label="Select Wallet"
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/60 ${className}`}
className={`fixed inset-0 z-50 flex items-center justify-center bg-surface-page/80 ${className}`}
>
<div
data-testid="wallet-selector-modal-content"
className="bg-surface rounded-xl shadow-xl max-w-md w-full mx-4 p-6"
className="bg-surface-card border border-border-subtle text-text-primary rounded-xl shadow-xl max-w-md w-full mx-4 p-6"
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-primary">
<h2 className="text-lg font-semibold text-text-primary">
Select Wallet
</h2>
<button
type="button"
onClick={onClose}
data-testid="wallet-selector-modal-close"
aria-label="Close"
className="text-secondary hover:text-primary transition-colors"
className="text-text-secondary hover:text-text-primary transition-colors"
>
</button>
Expand All @@ -249,7 +249,7 @@ export default function WalletSelectorModal({
{networkMismatch.mismatched && mismatchMessage && (
<div
data-testid="wallet-selector-network-warning"
className="bg-warning/40 border border-warning rounded-lg px-4 py-3 mb-4 text-warning-soft text-sm"
className="bg-warning-soft/10 border border-warning-soft/40 rounded-lg px-4 py-3 mb-4 text-warning-soft text-sm"
role="alert"
>
{mismatchMessage}
Expand All @@ -261,7 +261,7 @@ export default function WalletSelectorModal({
<div
data-testid="wallet-selector-availability-warning"
role="alert"
className="bg-warning/40 border border-warning rounded-lg px-4 py-3 mb-4 text-warning-soft text-sm"
className="bg-warning-soft/10 border border-warning-soft/40 rounded-lg px-4 py-3 mb-4 text-warning-soft text-sm"
>
<p data-testid="wallet-selector-setup-instruction">
{availability.setupInstruction}
Expand All @@ -282,7 +282,7 @@ export default function WalletSelectorModal({
{errorMessage && (
<div
data-testid="wallet-selector-error-message"
className="bg-danger/20 border border-danger rounded-lg px-4 py-3 mb-4 text-danger-soft text-sm text-center"
className="bg-danger/20 border border-danger/40 rounded-lg px-4 py-3 mb-4 text-danger-soft text-sm text-center"
role="alert"
>
{errorMessage}
Expand All @@ -294,7 +294,7 @@ export default function WalletSelectorModal({
<div
data-testid="wallet-selector-rejection-warning"
role="alert"
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-4 py-3 mb-4 text-sm text-yellow-300"
className="bg-warning-soft/10 border border-warning-soft/40 rounded-lg px-4 py-3 mb-4 text-sm text-warning-soft"
>
Signature cancelled — you rejected the request in your wallet.
</div>
Expand All @@ -305,7 +305,7 @@ export default function WalletSelectorModal({
<div
data-testid="wallet-selector-error-warning"
role="alert"
className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 mb-4 text-sm text-red-300"
className="bg-danger/10 border border-danger/30 rounded-lg px-4 py-3 mb-4 text-sm text-danger-soft"
>
Failed to connect wallet. Please try again.
</div>
Expand All @@ -315,11 +315,11 @@ export default function WalletSelectorModal({
{effectiveLoading && (
<div
data-testid="wallet-selector-spinner"
className="absolute inset-0 z-10 flex items-center justify-center bg-black/50 rounded-lg"
className="absolute inset-0 z-10 flex items-center justify-center bg-surface-page/60 rounded-lg"
>
<div className="flex flex-col items-center space-y-2">
<svg
className="h-8 w-8 text-indigo-500 animate-spin"
className="h-8 w-8 text-accent-soft animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
Expand All @@ -339,7 +339,7 @@ export default function WalletSelectorModal({
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="text-sm text-gray-300">
<span className="text-sm text-text-secondary">
Wallet operation in progress…
</span>
</div>
Expand All @@ -350,19 +350,19 @@ export default function WalletSelectorModal({
{activeAddress && (
<div
data-testid="wallet-selector-active-info"
className="mb-4 p-3 border border-white/10 rounded-lg"
className="mb-4 p-3 border border-border-subtle rounded-lg bg-surface-field"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm text-gray-300 font-mono">
<span className="h-2 w-2 rounded-full bg-success-soft animate-pulse" />
<span className="text-sm text-text-secondary font-mono">
{activeAddress.slice(0, 4)}...{activeAddress.slice(-4)}
</span>
</div>
<button
onClick={handleDisconnect}
disabled={effectiveLoading}
className="text-sm text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
className="text-sm text-danger-soft hover:text-danger-soft-hover transition-colors disabled:opacity-50"
data-testid="wallet-selector-disconnect-btn"
>
Disconnect
Expand All @@ -389,8 +389,8 @@ export default function WalletSelectorModal({
data-connected={isConnected}
className={`w-full text-left px-4 py-3 rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isSelected
? "border-indigo-500 bg-indigo-600/20 text-white"
: "border-white/10 hover:border-white/20 hover:bg-white/5 text-primary"
? "border-accent-soft bg-accent/10 text-text-primary"
: "border-border-subtle hover:border-accent-soft/60 hover:bg-surface-field text-text-primary"
}`}
>
<div className="flex items-center justify-between">
Expand All @@ -400,7 +400,7 @@ export default function WalletSelectorModal({
{isConnected && (
<span
data-testid="wallet-selector-connected-badge"
className="text-xs text-green-400"
className="text-xs text-success-soft"
>
Connected
</span>
Expand Down
69 changes: 67 additions & 2 deletions app/lib/wallet_disconnect_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,67 @@

const LOG_PREFIX = "[wallet_disconnect_handler]";

export const DEFAULT_WALLET_DISCONNECT_TIMEOUT_MS = 60_000;

export interface WalletDisconnectRequest {
payload?: Uint8Array | null;
}

export class WalletDisconnectTimeoutError extends Error {
constructor(public readonly timeoutMs: number) {
super(`Wallet disconnect timed out after ${timeoutMs}ms`);
this.name = "WalletDisconnectTimeoutError";
}
}

export function clearWalletDisconnectMemory(
request: WalletDisconnectRequest,
): WalletDisconnectRequest {
if (request.payload) request.payload.fill(0);
request.payload = null;
return request;
}

export interface WalletDisconnectTimeoutOptions {
timeoutMs?: number;
request?: WalletDisconnectRequest;
cleanup?: () => void;
}

export function runWalletDisconnectWithTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
options: WalletDisconnectTimeoutOptions = {},
): Promise<T> {
const timeoutMs = options.timeoutMs ?? DEFAULT_WALLET_DISCONNECT_TIMEOUT_MS;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return Promise.reject(new RangeError("timeoutMs must be a positive number"));
}

const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;

const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new WalletDisconnectTimeoutError(timeoutMs));
}, timeoutMs);
});
const operationPromise = Promise.resolve().then(() => operation(controller.signal));

const resultPromise = (async () => {
try {
return await Promise.race([operationPromise, timeoutPromise]);
} finally {
if (timer !== undefined) clearTimeout(timer);
if (options.request) clearWalletDisconnectMemory(options.request);
options.cleanup?.();
}
})();

resultPromise.catch(() => {});
return resultPromise;
}

// =============================================================
// Wallet availability detection
// =============================================================
Expand Down Expand Up @@ -158,8 +219,9 @@ export interface WalletDisconnectResult {
*/
export async function disconnectWalletWithCheck(
walletId: string,
disconnectFn: () => Promise<void>,
disconnectFn: (signal?: AbortSignal) => Promise<void>,
detector?: () => boolean,
options?: WalletDisconnectTimeoutOptions,
): Promise<WalletDisconnectResult> {
const availability = checkWalletAvailabilityById(walletId, detector);

Expand All @@ -176,7 +238,10 @@ export async function disconnectWalletWithCheck(
}

try {
await disconnectFn();
await runWalletDisconnectWithTimeout(
(signal) => disconnectFn(signal),
options,
);
return {
success: true,
error: null,
Expand Down