Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/components/ContractEventFeed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -664,4 +664,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(
<ContractEventFeed contractId={CONTRACT_ID} pollInterval={500} />,
);
act(() => { vi.advanceTimersByTime(0); });
await waitFor(() => expect(getEvents).toHaveBeenCalledTimes(1));
expect(getEvents).toHaveBeenLastCalledWith(CONTRACT_ID, 10, undefined);

rerender(<ContractEventFeed contractId={NEW_ID} pollInterval={500} />);
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(<ContractEventFeed contractId={CONTRACT_ID} pollInterval={500} />);
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);
});
});
});
38 changes: 38 additions & 0 deletions src/components/SorobanPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ vi.mock("@/context/useSorokit", () => ({
useSorokit: vi.fn(() => ({
isConnected: true,
address: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA",
client: {
soroban: {
invokeContract: mockInvokeContract,
simulateContract: mockSimulateContract,
},
},
})),
}));

Expand All @@ -30,6 +36,12 @@ describe("SorobanPanel", () => {
vi.mocked(useSorokit).mockReturnValue({
isConnected: true,
address: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA",
client: {
soroban: {
invokeContract: mockInvokeContract,
simulateContract: mockSimulateContract,
},
},
} as unknown as ReturnType<typeof useSorokit>);
});

Expand All @@ -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(<SorobanPanel contractId="C123" onContractIdChange={() => {}} />);
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; };
Expand Down
14 changes: 14 additions & 0 deletions src/components/TransactionPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ describe("TransactionPanel", () => {
expect(dialog).toHaveTextContent("GBRPYHIL...ONXHPA");
});

// 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(<TransactionPanel />);

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);
Expand Down
55 changes: 45 additions & 10 deletions src/lib/mock-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,52 @@ describe("mock-client", () => {
expect(page1.total).toBe(MOCK_HISTORY.length);
});

it("verifies instance isolation between multiple createMockClient invocations", async () => {
// 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 clientA = createMockClient("testnet");
const clientB = createMockClient("public");

if ("network" in clientA && "network" in clientB) {
const netA = await clientA.network.getNetwork();
const netB = await clientB.network.getNetwork();
expect(netA.data?.name).toBe("testnet");
expect(netB.data?.name).toBe("mainnet");
}
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);
});
});

6 changes: 5 additions & 1 deletion src/lib/mock-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,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,
}),
Expand Down