From 9c080e704914007192822ae295dcb316e9eace5f Mon Sep 17 00:00:00 2001 From: Dev Fashman Date: Sat, 29 Aug 2026 00:52:36 -0400 Subject: [PATCH] fix(soroban): return real mock results, pin #581/#582 with regression tests - mock-client invokeContract now resolves with a deterministic result instead of null data, so the Soroban screen renders a real result (fixes the 'Not implemented' symptom from #573). - add regression tests covering the #581 native form submission (type=submit buttons linked to the parent form) and #582 ContractEventFeed polling restart on contractId change. - fix SorobanPanel/TransactionPanel test harness so the mocked context exposes the client the panels actually read. --- src/components/ContractEventFeed.test.tsx | 73 +++++++++++++++++++++++ src/components/SorobanPanel.test.tsx | 38 ++++++++++++ src/components/TransactionPanel.test.tsx | 29 ++++++++- src/lib/mock-client.test.ts | 48 +++++++++++++++ src/lib/mock-client.ts | 6 +- 5 files changed, 191 insertions(+), 3 deletions(-) diff --git a/src/components/ContractEventFeed.test.tsx b/src/components/ContractEventFeed.test.tsx index 99432b7..f93840b 100644 --- a/src/components/ContractEventFeed.test.tsx +++ b/src/components/ContractEventFeed.test.tsx @@ -620,4 +620,77 @@ describe("ContractEventFeed", () => { ); }); }); + + // ── Polling stale-closure regression (#582) ────────────────────────────── + // The polling interval used to close over the `load` instance captured when + // the effect first ran, so changing the `contractId` prop kept polling the + // OLD contract. These tests pin the fixed behaviour: the interval restarts + // with the current contractId. + describe("polling contractId switching (#582)", () => { + const NEW_ID = + "CBBB4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA"; + + it("restarts polling for the new contractId after the prop changes", async () => { + const getEvents = vi.fn().mockResolvedValue({ data: [], error: null }); + vi.mocked(getClient).mockReturnValue({ + soroban: { getEvents }, + } as unknown as SorokitClient); + + const { rerender } = render( + , + ); + act(() => { vi.advanceTimersByTime(0); }); + await waitFor(() => expect(getEvents).toHaveBeenCalledTimes(1)); + expect(getEvents).toHaveBeenLastCalledWith(CONTRACT_ID, 10, undefined); + + rerender(); + act(() => { vi.advanceTimersByTime(0); }); + + // The restarted effect loads the new contract immediately, then keeps + // polling it. Capture the call count here so the stale-closure check + // only examines calls made *after* the switch. + await waitFor(() => { + expect(getEvents).toHaveBeenLastCalledWith(NEW_ID, 10, undefined); + }); + const callsAfterSwitch = getEvents.mock.calls.length; + + // Advance past one full poll interval. The stale-closure bug (#582) + // would keep calling with the OLD contractId here; the fixed code must + // use NEW_ID for every poll after the switch. + act(() => { vi.advanceTimersByTime(500); }); + await waitFor(() => { + expect(getEvents.mock.calls.length).toBeGreaterThan(callsAfterSwitch); + }); + + const postSwitchIds = getEvents.mock.calls + .slice(callsAfterSwitch) + .map(([id]) => id); + expect(postSwitchIds).not.toContain(CONTRACT_ID); + expect(postSwitchIds.every((id) => id === NEW_ID)).toBe(true); + }); + + it("resumes polling for the current contract when Live is toggled back on", async () => { + const getEvents = vi.fn().mockResolvedValue({ data: [], error: null }); + vi.mocked(getClient).mockReturnValue({ + soroban: { getEvents }, + } as unknown as SorokitClient); + + render(); + act(() => { vi.advanceTimersByTime(0); }); + await waitFor(() => expect(getEvents).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole("button", { name: /live/i })); + const pausedCount = getEvents.mock.calls.length; + act(() => { vi.advanceTimersByTime(1500); }); + expect(getEvents).toHaveBeenCalledTimes(pausedCount); + + fireEvent.click(screen.getByRole("button", { name: /paused/i })); + act(() => { vi.advanceTimersByTime(500); }); + + await waitFor(() => { + expect(getEvents).toHaveBeenCalledTimes(pausedCount + 1); + }); + expect(getEvents).toHaveBeenLastCalledWith(CONTRACT_ID, 10, undefined); + }); + }); }); diff --git a/src/components/SorobanPanel.test.tsx b/src/components/SorobanPanel.test.tsx index 4bb677a..8f0a9be 100644 --- a/src/components/SorobanPanel.test.tsx +++ b/src/components/SorobanPanel.test.tsx @@ -12,6 +12,12 @@ vi.mock("@/context/useSorokit", () => ({ useSorokit: vi.fn(() => ({ isConnected: true, address: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA", + client: { + soroban: { + invokeContract: mockInvokeContract, + simulateContract: mockSimulateContract, + }, + }, })), })); @@ -30,6 +36,12 @@ describe("SorobanPanel", () => { vi.mocked(useSorokit).mockReturnValue({ isConnected: true, address: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA", + client: { + soroban: { + invokeContract: mockInvokeContract, + simulateContract: mockSimulateContract, + }, + }, } as unknown as ReturnType); }); @@ -39,6 +51,32 @@ describe("SorobanPanel", () => { expect(screen.getByRole("button", { name: /invoke/i })).toBeDisabled(); }); + // Issue #581 — the Invoke button must submit the parent form natively + // (type="submit") instead of re-dispatching the FormEvent handler through + // an unsafe `as unknown as React.MouseEventHandler` onClick cast. + it("submits the form natively via type=submit linked to the form's id", async () => { + mockInvokeContract.mockResolvedValueOnce({ + data: { success: true }, + error: null, + }); + render( {}} />); + fireEvent.change(screen.getByLabelText("Method"), { + target: { value: "balance" }, + }); + + const invokeButton = screen.getByRole("button", { name: /invoke/i }); + const form = document.querySelector("form"); + expect(invokeButton).toHaveAttribute("type", "submit"); + expect(form).not.toBeNull(); + expect(invokeButton).toHaveAttribute("form", form!.id); + + // Clicking the button reaches the form's onSubmit handler and produces + // a real invocation — not "Not implemented". + fireEvent.click(invokeButton); + await screen.findByText("Result"); + expect(mockInvokeContract).toHaveBeenCalledOnce(); + }); + it("should show error when invalid JSON args are provided", async () => { let currentContractId = ""; const setContractId = (id: string) => { currentContractId = id; }; diff --git a/src/components/TransactionPanel.test.tsx b/src/components/TransactionPanel.test.tsx index d109556..eed1c16 100644 --- a/src/components/TransactionPanel.test.tsx +++ b/src/components/TransactionPanel.test.tsx @@ -22,12 +22,23 @@ function mockGetClient( .fn() .mockResolvedValue({ data: DEFAULT_FEE, error: null }), ) { - vi.mocked(getClient).mockReturnValue({ + const client = { transaction: { submit: submitImpl, estimateFee: feeImpl, }, - } as unknown as ReturnType); + }; + vi.mocked(getClient).mockReturnValue( + client as unknown as ReturnType, + ); + // The panel reads the client from the useSorokit context (not the getClient + // singleton), so submissions only reach the mocked API when the context + // exposes it too. + vi.mocked(useSorokit).mockReturnValue({ + address: "GABC", + isConnected: true, + client, + } as unknown as ReturnType); } /** Clicks the Send button (label varies by selected asset), waits for the confirmation modal, then confirms. */ @@ -67,6 +78,20 @@ describe("TransactionPanel", () => { expect(dialog).toHaveTextContent("GABC"); }); + // Issue #581 — the Send Payment button submits the form natively + // (type="submit"), linked to the form via its `form` attribute, instead of + // re-dispatching a FormEvent handler through an unsafe `as unknown as` + // onClick cast. + it("renders the Send Payment button as a type=submit button tied to the form", () => { + render(); + + const form = document.querySelector("form"); + expect(form).not.toBeNull(); + const sendButton = screen.getByRole("button", { name: /^Send (XLM|USDC)/ }); + expect(sendButton).toHaveAttribute("type", "submit"); + expect(sendButton).toHaveAttribute("form", form!.id); + }); + it("does not submit until Confirm & Sign is clicked in the modal", async () => { const mockSubmit = vi.fn().mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null }); mockGetClient(mockSubmit); diff --git a/src/lib/mock-client.test.ts b/src/lib/mock-client.test.ts index 673c769..65e4889 100644 --- a/src/lib/mock-client.test.ts +++ b/src/lib/mock-client.test.ts @@ -70,4 +70,52 @@ describe("mock-client", () => { expect(page2.data?.[0].hash).toBe(MOCK_HISTORY[limit].hash); expect(page1.total).toBe(MOCK_HISTORY.length); }); + + // Issue #573 — the Soroban screen must be functional against the mock + // client: invokeContract/simulateContract return real result data (never + // "Not implemented") and getEvents returns actual events. + it("verifySoroban invokeContract resolves with result data, not 'Not implemented'", async () => { + const { createMockClient, MOCK_ADDRESS: mockAddress } = await import("./mock-client"); + const client = createMockClient(); + + const res = await client.soroban.invokeContract({ + contractId: "C123", + method: "transfer", + args: [], + sourceAccount: mockAddress, + }); + + expect(res.error).toBeNull(); + expect(res.status).toBe("success"); + expect(res.data).not.toBeNull(); + expect(res.data).not.toBe("Not implemented"); + expect(res.data).toHaveProperty("success", true); + expect(res.data).toHaveProperty("txHash"); + }); + + it("verifySoroban simulateContract resolves with result data", async () => { + const { createMockClient } = await import("./mock-client"); + const client = createMockClient(); + + const res = await client.soroban.simulateContract({ + contractId: "C123", + method: "balance", + args: [{ address: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA" }], + }); + + expect(res.error).toBeNull(); + expect(res.status).toBe("success"); + expect(res.data).not.toBeNull(); + }); + + it("verifySoroban getEvents returns event data for a contract", async () => { + const { createMockClient } = await import("./mock-client"); + const client = createMockClient(); + + const res = await client.soroban.getEvents("C123", 10); + + expect(res.error).toBeNull(); + expect(Array.isArray(res.data)).toBe(true); + expect(res.data!.length).toBeGreaterThan(0); + }); }); diff --git a/src/lib/mock-client.ts b/src/lib/mock-client.ts index 40bb179..f9a48e9 100644 --- a/src/lib/mock-client.ts +++ b/src/lib/mock-client.ts @@ -407,7 +407,11 @@ export function createMockClient( }, soroban: { invokeContract: async (_params: InvokeParams) => ({ - data: null, + data: { + success: true, + result: { status: "ok", output: "mock-invoke-output" }, + txHash: deterministicMock.generateTransactionHash(), + }, error: null, status: "success" as const, }),