diff --git a/__tests__/freighter_connector.component.test.tsx b/__tests__/freighter_connector.component.test.tsx index 5b0fdf4..ae075c8 100644 --- a/__tests__/freighter_connector.component.test.tsx +++ b/__tests__/freighter_connector.component.test.tsx @@ -8,6 +8,39 @@ import { HIGH_FEE_THRESHOLD_STROOPS, } from "@/app/lib/freighter_connector"; +// WalletProvider pulls @creit.tech/stellar-wallets-kit at module scope, whose +// bundled UMD dependencies are not Node-ESM-importable. This component suite +// renders FreighterGasWarningBanner inside FreighterConnector via +// GasEstimationWarningBanner, which only reads `gasWarning` from the wallet +// context — stubbing `useWallet` with the real provider's default shape keeps +// the real freighter_connector logic under test. +const walletContextMock = vi.hoisted(() => ({ + useWallet: () => ({ + address: null, + assembleMultiSigTransaction: vi.fn(async () => ({ + uniqueSigners: 0, + splitsValidated: 0, + })), + connect: vi.fn(async () => {}), + disconnect: vi.fn(), + isConnecting: false, + networkMismatchMessage: null, + selectedWalletId: "freighter", + setSelectedWalletId: vi.fn(), + signTransaction: vi.fn(async () => ""), + signatureTimeoutError: null, + signatureTimeoutXdr: null, + clearSignatureTimeout: vi.fn(), + simulationResult: null, + setSimulationResult: vi.fn(), + gasWarning: null, + }), +})); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: walletContextMock.useWallet, +})); + // --------------------------------------------------------------------------- // #112 — React Testing Library assertions for freighter_connector // --------------------------------------------------------------------------- diff --git a/__tests__/freighter_multisig_hook.test.ts b/__tests__/freighter_multisig_hook.test.ts index f4f82bb..608b42c 100644 --- a/__tests__/freighter_multisig_hook.test.ts +++ b/__tests__/freighter_multisig_hook.test.ts @@ -1,5 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { Account, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; import { useFreighterMultiSigAssembly } from "@/app/hooks/useFreighterMultiSigAssembly"; import { WalletMultiSigStructureError } from "@/app/lib/wallet_state_context"; import { @@ -13,13 +14,31 @@ function toBase64(text: string): string { const TEST_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; +// The hook validates XDR against a real @stellar/stellar-sdk parser, so the +// fixtures must be genuine signed transaction envelopes rather than arbitrary +// bytes. Building a real envelope (and signing it so `signatures` is +// populated) exercises the parser instead of failing it. +function buildSignedEnvelope(): string { + const keypair = Keypair.random(); + const account = new Account(keypair.publicKey(), "0"); + const transaction = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.bumpSequence({ bumpTo: "0" })) + .setTimeout(30) + .build(); + transaction.sign(keypair); + return transaction.toEnvelope().toXDR("base64"); +} + describe("useFreighterMultiSigAssembly hook (#109)", () => { it("parseStructure parses a well-formed envelope without errors", () => { const { result } = renderHook(() => useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("a".repeat(200)); + const xdr = buildSignedEnvelope(); const shape = result.current.parseStructure(xdr); expect(shape.baseXdr).toBe(xdr); @@ -42,7 +61,7 @@ describe("useFreighterMultiSigAssembly hook (#109)", () => { useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("a".repeat(200)); + const xdr = buildSignedEnvelope(); const tx = result.current.prepareTransaction(xdr); expect(tx).toBeDefined(); }); @@ -62,7 +81,7 @@ describe("useFreighterMultiSigAssembly hook (#109)", () => { useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("b".repeat(200)); + const xdr = buildSignedEnvelope(); const tx = result.current.prepareTransaction(xdr); const serialized = result.current.serializeTransaction(tx); expect(typeof serialized).toBe("string"); @@ -74,7 +93,7 @@ describe("useFreighterMultiSigAssembly hook (#109)", () => { useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("c".repeat(200)); + const xdr = buildSignedEnvelope(); const signFn = vi.fn(async () => "signed-xdr"); const signed = await result.current.signTransaction(xdr, signFn); diff --git a/__tests__/network_sync_checker_logging.test.ts b/__tests__/network_sync_checker_logging.test.ts new file mode 100644 index 0000000..ae95312 --- /dev/null +++ b/__tests__/network_sync_checker_logging.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + formatConsoleWarningBlock, + formatStackTrace, + logNetworkSyncWarning, + networkSyncTracker, + runNetworkSyncSign, + validateNetworkSyncWithSignature, +} from "@/app/lib/network_sync_checker"; + +const LOG_PREFIX = "[network_sync_checker]"; + +describe("network_sync_checker formatted console output", () => { + beforeEach(() => { + networkSyncTracker.clear(); + }); + + describe("formatStackTrace", () => { + it("returns the real stack of an Error", () => { + const stack = formatStackTrace(new Error("boom")); + expect(stack).toContain("Error: boom"); + expect(stack).toContain("\n"); + }); + + it("keeps multi-line strings as-is", () => { + const lines = "line one\nline two"; + expect(formatStackTrace(lines)).toBe(lines); + }); + + it("synthesizes a stack for primitives and single-line values", () => { + expect(formatStackTrace("just a message")).toContain( + "Error: just a message" + ); + expect(formatStackTrace(undefined)).toContain("network_sync_checker trace"); + }); + }); + + describe("formatConsoleWarningBlock", () => { + it("renders a bordered block with title, body, txId, phase and stack frames", () => { + const block = formatConsoleWarningBlock({ + title: "TX ERROR", + body: "signature rejected during network sync", + stack: "Error: nope\n at probe (file.ts:1:1)", + txId: "sync-123", + phase: "error", + }); + + expect(block).toContain(`${LOG_PREFIX} ╔══`); + expect(block).toContain(`${LOG_PREFIX} ╚══`); + expect(block).toContain("TX ERROR"); + expect(block).toContain(`${LOG_PREFIX} signature rejected during network sync`); + expect(block).toContain(`${LOG_PREFIX} txId: sync-123`); + expect(block).toContain(`${LOG_PREFIX} phase: error`); + expect(block).toContain(`${LOG_PREFIX} --- stack trace ---`); + expect(block).toContain(`${LOG_PREFIX} at probe (file.ts:1:1)`); + expect(block).toContain(`${LOG_PREFIX} --- end stack ---`); + }); + }); + + describe("logNetworkSyncWarning", () => { + it("logs a single formatted warning block to the console", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const formatted = logNetworkSyncWarning( + "NETWORK MISMATCH", + "wallet availability check failed", + { phase: "checking" } + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0][0])).toContain(LOG_PREFIX); + expect(String(warnSpy.mock.calls[0][0])).toContain("NETWORK MISMATCH"); + expect(formatted).toContain(LOG_PREFIX); + warnSpy.mockRestore(); + }); + }); +}); + +describe("network_sync_checker transaction tracking", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + networkSyncTracker.clear(); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("records successful probes without console noise", async () => { + const result = await runNetworkSyncSign(async () => "signed", vi.fn(), { + txId: "sync-ok", + }); + + expect(result).toBe("signed"); + expect(warnSpy).not.toHaveBeenCalled(); + + const history = networkSyncTracker.getHistory("sync-ok"); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + txId: "sync-ok", + phase: "success", + message: "network sync probe signed", + }); + }); + + it("emits one formatted TX ERROR block when the user rejects the probe", async () => { + const showToast = vi.fn(); + + const result = await runNetworkSyncSign(async () => { + throw new Error("user rejected transaction"); + }, showToast); + + expect(result).toBeNull(); + expect(showToast).toHaveBeenCalledWith( + "Network sync cancelled — you rejected the signature in your wallet.", + "warning" + ); + expect(warnSpy).toHaveBeenCalledTimes(1); + + const logged = String(warnSpy.mock.calls[0][0]); + expect(logged).toContain(LOG_PREFIX); + expect(logged).toContain("TX ERROR"); + expect(logged).toContain("signature rejected during network sync"); + expect(logged).toContain("phase: error"); + + const history = networkSyncTracker.getHistory("network-sync-probe"); + expect(history).toHaveLength(1); + expect(history[0].phase).toBe("error"); + }); + + it("emits no tracking block and no warn when a non-rejection error rethrows", async () => { + await expect( + runNetworkSyncSign(async () => { + throw new Error("horizon unreachable"); + }, vi.fn()) + ).rejects.toThrow("horizon unreachable"); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(networkSyncTracker.getHistory()).toHaveLength(0); + }); + + it("tracks the out-of-sync failure in validateNetworkSyncWithSignature", async () => { + const showToast = vi.fn(); + const signFn = vi.fn(); + + const result = await validateNetworkSyncWithSignature( + "mainnet", + "testnet", + signFn, + showToast, + { txId: "sync-mismatch" } + ); + + expect(result).toBeNull(); + expect(signFn).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith( + expect.stringMatching(/Network out of sync/i), + "warning" + ); + expect(warnSpy).toHaveBeenCalledTimes(1); + + const logged = String(warnSpy.mock.calls[0][0]); + expect(logged).toContain(LOG_PREFIX); + expect(logged).toContain("network out of sync"); + + const history = networkSyncTracker.getHistory("sync-mismatch"); + expect(history).toHaveLength(1); + expect(history[0].phase).toBe("error"); + }); + + it("records success through validateNetworkSyncWithSignature when aligned", async () => { + const result = await validateNetworkSyncWithSignature( + "testnet", + "testnet", + async () => "signed", + vi.fn(), + { txId: "sync-align" } + ); + + expect(result).toBe("signed"); + expect(warnSpy).not.toHaveBeenCalled(); + expect(networkSyncTracker.getHistory("sync-align")[0].phase).toBe("success"); + }); + + it("filters history by txId and clears the tracker", () => { + networkSyncTracker.track("a", "signing", "start"); + networkSyncTracker.track("b", "signing", "start"); + + expect(networkSyncTracker.getHistory("a")).toHaveLength(1); + expect(networkSyncTracker.getHistory()).toHaveLength(2); + + networkSyncTracker.clear(); + expect(networkSyncTracker.getHistory()).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/__tests__/signature_timeout_alert.test.tsx b/__tests__/signature_timeout_alert.test.tsx index d356886..f3c8a79 100644 --- a/__tests__/signature_timeout_alert.test.tsx +++ b/__tests__/signature_timeout_alert.test.tsx @@ -1,9 +1,43 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import SignatureTimeoutAlert from "@/app/components/SignatureTimeoutAlert"; import WalletLoaderOverlay from "@/app/components/WalletLoaderOverlay"; import { endWalletOperation, startWalletOperation } from "@/app/lib/wallet_state_context"; +// WalletProvider pulls @creit.tech/stellar-wallets-kit at module scope, whose +// bundled UMD dependencies are not Node-ESM-importable. These suites never +// exercise provider-driven wallet state (they render the components with bare +// props), so `useWallet` is stubbed with the same defaults the real +// WalletContext provides via `createContext`. The real wallet library modules +// (wallet_state_context, freighter_connector, albedo_connector, +// ledger_usb_bridge) stay under test. +const walletContextMock = vi.hoisted(() => ({ + useWallet: () => ({ + address: null, + assembleMultiSigTransaction: vi.fn(async () => ({ + uniqueSigners: 0, + splitsValidated: 0, + })), + connect: vi.fn(async () => {}), + disconnect: vi.fn(), + isConnecting: false, + networkMismatchMessage: null, + selectedWalletId: "albedo", + setSelectedWalletId: vi.fn(), + signTransaction: vi.fn(async () => ""), + signatureTimeoutError: null, + signatureTimeoutXdr: null, + clearSignatureTimeout: vi.fn(), + simulationResult: null, + setSimulationResult: vi.fn(), + gasWarning: null, + }), +})); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: walletContextMock.useWallet, +})); + describe("SignatureTimeoutAlert", () => { it("renders timeout details and logs a formatted stack trace", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -54,9 +88,13 @@ describe("SignatureTimeoutAlert", () => { it("keeps the loader counter balanced when an external operation is active", async () => { render(); - startWalletOperation(); + act(() => { + startWalletOperation(); + }); expect(screen.getByTestId("wallet-loader-overlay")).toBeInTheDocument(); - endWalletOperation(); + act(() => { + endWalletOperation(); + }); await waitFor(() => expect(screen.queryByTestId("wallet-loader-overlay")).not.toBeInTheDocument()); }); }); diff --git a/__tests__/signature_timeout_alert_timeout.test.ts b/__tests__/signature_timeout_alert_timeout.test.ts index dd38e25..f0c0599 100644 --- a/__tests__/signature_timeout_alert_timeout.test.ts +++ b/__tests__/signature_timeout_alert_timeout.test.ts @@ -107,8 +107,9 @@ describe("signature_timeout_alert timeout bounds (#244)", () => { const signFn = vi.fn(() => new Promise(() => {})); const promise = runSignatureWithTimeout(request, signFn, 1_000); + const onRejected = promise.catch(() => {}); await vi.advanceTimersByTimeAsync(1_000); - await promise.catch(() => {}); + await onRejected; expect(request.payload).toBeNull(); expect(payload.every((b) => b === 0)).toBe(true); @@ -131,8 +132,9 @@ describe("signature_timeout_alert timeout bounds (#244)", () => { const signFn = vi.fn(() => new Promise(() => {})); const promise = runSignatureWithTimeout(request, signFn, 500); + const onRejected = promise.catch(() => {}); await vi.advanceTimersByTimeAsync(500); - await promise.catch(() => {}); + await onRejected; // Memory cleared — operation cannot leak sensitive bytes post-abort. expect(request.payload).toBeNull(); @@ -210,8 +212,9 @@ describe("signature_timeout_alert timeout bounds (#244)", () => { }); const promise = runSignatureWithTimeout(request, signFn, 5_000); + const onRejected = promise.catch(() => {}); await vi.runAllTimersAsync(); - await promise.catch(() => {}); + await onRejected; // Memory should NOT be cleared for non-timeout errors (unmodified request) // The timeout path specifically clears; other errors pass through as-is. diff --git a/app/components/SignatureTimeoutAlert.tsx b/app/components/SignatureTimeoutAlert.tsx index eec9aaf..668468f 100644 --- a/app/components/SignatureTimeoutAlert.tsx +++ b/app/components/SignatureTimeoutAlert.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useWallet } from "@/app/context/WalletContext"; import { NETWORK_PASSPHRASE } from "@/app/lib/contract"; import { @@ -50,7 +50,6 @@ export default function SignatureTimeoutAlert({ const albedoAssembly = useAlbedoMultiSigAssembly(NETWORK_PASSPHRASE); const ledgerAssembly = useLedgerMultiSigAssembly(NETWORK_PASSPHRASE); const [isRetrying, setIsRetrying] = useState(false); - const [parseMessage, setParseMessage] = useState(null); const activeError = error ?? signatureTimeoutError; const activeTransactionXdr = transactionXdr ?? signatureTimeoutXdr ?? undefined; @@ -69,21 +68,18 @@ export default function SignatureTimeoutAlert({ }); }, [activeError, hasTimeout, networkMismatchMessage, transactionId]); - useEffect(() => { - if (!activeTransactionXdr) { - setParseMessage(null); - return; - } + // Derived state: parse failures are computed on render instead of being + // written to state from an effect (avoids cascading renders). + const parseMessage = useMemo(() => { + if (!activeTransactionXdr) return null; try { parseMultiSigEnvelope(activeTransactionXdr, { parseEnvelopeXdr: createStellarEnvelopeParser(NETWORK_PASSPHRASE), }); - setParseMessage(null); + return null; } catch (parseError) { - setParseMessage( - parseError instanceof Error ? parseError.message : String(parseError) - ); + return parseError instanceof Error ? parseError.message : String(parseError); } }, [activeTransactionXdr]); diff --git a/app/lib/network_sync_checker.ts b/app/lib/network_sync_checker.ts index 13a1602..893a0fa 100644 --- a/app/lib/network_sync_checker.ts +++ b/app/lib/network_sync_checker.ts @@ -28,6 +28,136 @@ export interface WalletAvailabilityState { const LOG_PREFIX = "[network_sync_checker]"; +export type NetworkSyncTxPhase = + | "idle" + | "checking" + | "signing" + | "success" + | "error"; + +export interface NetworkSyncTxTrackEntry { + txId: string; + phase: NetworkSyncTxPhase; + message: string; + timestamp: number; + stack?: string; +} + +export interface NetworkSyncConsoleWarningBlock { + title: string; + body: string; + stack: string; + txId?: string; + phase?: NetworkSyncTxPhase; +} + +/** Captures a normalized stack string from an error or the current call site. */ +export function formatStackTrace(err?: unknown): string { + if (err instanceof Error && err.stack) { + return err.stack; + } + + if (typeof err === "string" && err.includes("\n")) { + return err; + } + + const synthetic = new Error( + typeof err === "string" ? err : "network_sync_checker trace" + ); + return synthetic.stack ?? "Error: network_sync_checker trace"; +} + +/** Builds a multi-line console warning block for transaction debug tracking. */ +export function formatConsoleWarningBlock( + block: NetworkSyncConsoleWarningBlock +): string { + const lines = [ + `${LOG_PREFIX} ╔══════════════════════════════════════╗`, + `${LOG_PREFIX} ║ ${block.title.padEnd(36).slice(0, 36)} ║`, + `${LOG_PREFIX} ╚══════════════════════════════════════╝`, + `${LOG_PREFIX} ${block.body}`, + ]; + + if (block.txId) { + lines.push(`${LOG_PREFIX} txId: ${block.txId}`); + } + if (block.phase) { + lines.push(`${LOG_PREFIX} phase: ${block.phase}`); + } + + lines.push(`${LOG_PREFIX} --- stack trace ---`); + for (const frame of block.stack.split("\n")) { + lines.push(`${LOG_PREFIX} ${frame}`); + } + lines.push(`${LOG_PREFIX} --- end stack ---`); + + return lines.join("\n"); +} + +/** Logs a formatted warning block (including stack) to the console. */ +export function logNetworkSyncWarning( + title: string, + body: string, + options?: { err?: unknown; txId?: string; phase?: NetworkSyncTxPhase } +): string { + const stack = formatStackTrace(options?.err); + const formatted = formatConsoleWarningBlock({ + title, + body, + stack, + txId: options?.txId, + phase: options?.phase, + }); + console.warn(formatted); + return formatted; +} + +export class NetworkSyncTransactionTracker { + private entries: NetworkSyncTxTrackEntry[] = []; + + /** + * Records a transaction lifecycle event. Emits the formatted console block + * only when `err` is present or the phase is "error", so healthy probe runs + * stay quiet while failures remain greppable in the console. + */ + track( + txId: string, + phase: NetworkSyncTxPhase, + message: string, + err?: unknown + ): NetworkSyncTxTrackEntry { + const entry: NetworkSyncTxTrackEntry = { + txId, + phase, + message, + timestamp: Date.now(), + stack: formatStackTrace(err), + }; + this.entries.push(entry); + + if (err !== undefined || phase === "error") { + logNetworkSyncWarning(`TX ${phase.toUpperCase()}`, message, { + err, + txId, + phase, + }); + } + + return entry; + } + + getHistory(txId?: string): NetworkSyncTxTrackEntry[] { + if (!txId) return [...this.entries]; + return this.entries.filter((e) => e.txId === txId); + } + + clear(): void { + this.entries = []; + } +} + +export const networkSyncTracker = new NetworkSyncTransactionTracker(); + /** Default bound for wallet signature probes during network sync. */ export const DEFAULT_SIGNATURE_TIMEOUT_MS = 60_000; @@ -146,21 +276,34 @@ export function checkNetworkSync( }; } +export interface NetworkSyncSignOptions { + /** Optional identifier tracked through the probe lifecycle for debugging. */ + txId?: string; +} + /** * Runs a wallet signature step during network sync validation. Catches - * "user rejected transaction" exceptions, logs them, and shows a warning toast. + * "user rejected transaction" exceptions, logs them as a formatted warning + * block, and shows a warning toast. Healthy approvals are recorded on the + * transaction tracker without console noise. */ export async function runNetworkSyncSign( signFn: () => Promise, - showToast: SyncToastHandler + showToast: SyncToastHandler, + options: NetworkSyncSignOptions = {} ): Promise { + const txId = options.txId ?? "network-sync-probe"; try { - return await signFn(); + const result = await signFn(); + networkSyncTracker.track(txId, "success", "network sync probe signed"); + return result; } catch (err) { if (isNetworkSyncUserRejected(err)) { - console.warn( - `${LOG_PREFIX} signature rejected during network sync:`, - err instanceof Error ? err.message : err + networkSyncTracker.track( + txId, + "error", + "signature rejected during network sync", + err ); showToast( "Network sync cancelled — you rejected the signature in your wallet.", @@ -180,14 +323,21 @@ export async function validateNetworkSyncWithSignature( walletNetwork: SyncNetwork, appNetwork: SyncNetwork, signFn: () => Promise, - showToast: SyncToastHandler + showToast: SyncToastHandler, + options: NetworkSyncSignOptions = {} ): Promise { const state = checkNetworkSync(walletNetwork, appNetwork); if (!state.synced && state.warningMessage) { + networkSyncTracker.track( + options.txId ?? "network-sync-probe", + "error", + "network out of sync", + new Error(state.warningMessage) + ); showToast(state.warningMessage, "warning"); return null; } - return runNetworkSyncSign(signFn, showToast); + return runNetworkSyncSign(signFn, showToast, options); } /** @@ -228,16 +378,17 @@ export function checkWalletAvailability( warningMessage: null, }; } - return { +return { available: false, status: "unavailable", setupInstruction: WALLET_SETUP_INSTRUCTION, warningMessage: WALLET_SETUP_INSTRUCTION, }; } catch (err) { - console.warn( - `${LOG_PREFIX} wallet availability check failed:`, - err instanceof Error ? err.message : err + logNetworkSyncWarning( + "WALLET AVAILABILITY FAILED", + "wallet availability check failed", + { err, phase: "checking" } ); return { available: false, @@ -278,10 +429,10 @@ export function saveNetworkSyncSession(state: NetworkSyncSessionState): void { try { sessionStorage.setItem(NETWORK_SYNC_ACTIVE_ADDRESS_KEY, JSON.stringify(state)); } catch (err) { - console.warn( - `${LOG_PREFIX} failed to save session state:`, - err instanceof Error ? err.message : err - ); + logNetworkSyncWarning("SESSION SAVE FAILED", "failed to save session state", { + err, + phase: "idle", + }); } } @@ -301,9 +452,10 @@ export function loadNetworkSyncSession(): NetworkSyncSessionState | null { }; } } catch (err) { - console.warn( - `${LOG_PREFIX} failed to parse session state:`, - err instanceof Error ? err.message : err + logNetworkSyncWarning( + "SESSION PARSE FAILED", + "failed to parse session state", + { err, phase: "idle" } ); } return null; @@ -317,10 +469,10 @@ export function clearNetworkSyncSession(): void { try { sessionStorage.removeItem(NETWORK_SYNC_ACTIVE_ADDRESS_KEY); } catch (err) { - console.warn( - `${LOG_PREFIX} failed to clear session state:`, - err instanceof Error ? err.message : err - ); + logNetworkSyncWarning("SESSION CLEAR FAILED", "failed to clear session state", { + err, + phase: "idle", + }); } } @@ -822,9 +974,10 @@ function stringifyStellarAccount(value: unknown): string | null { const result = (accountId as () => unknown).call(value); if (typeof result === "string" && result.length > 0) return result; } catch (err) { - console.warn( - `${LOG_PREFIX} stellar accountId() extraction failed:`, - err instanceof Error ? err.message : err + logNetworkSyncWarning( + "STELLAR ACCOUNT EXTRACTION FAILED", + "stellar accountId() extraction failed", + { err, phase: "signing" } ); } } diff --git a/vitest.setup.ts b/vitest.setup.ts index f149f27..3bca877 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1 +1,40 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom environment: normalize the web-storage globals across Node versions. +// Newer Node releases ship an experimental `localStorage` global that is +// `undefined` unless `--localstorage-file` is passed; that value shadows the +// jsdom implementation in vitest, crashing tests that read bare +// `localStorage`. When the jsdom-provided global did not install, fall back +// to an in-memory Storage so the API stays available and testable. +if (typeof globalThis.localStorage === "undefined") { + function createMemoryStorage(): Storage { + const store = new Map(); + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + const value = store.get(key); + return value === undefined ? null : value; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, String(value)); + }, + }; + } + + Object.defineProperty(globalThis, "localStorage", { + value: createMemoryStorage(), + configurable: true, + writable: true, + }); +}