Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(keychain): trigger fee estimation on transaction change #1294

Merged
merged 6 commits into from
Jan 20, 2025
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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jobs:
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm test:ci --coverage
- uses: codecov/codecov-action@v3
with:
Expand Down
2 changes: 2 additions & 0 deletions packages/keychain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
"@storybook/react-vite": "catalog:",
"@storybook/test": "catalog:",
"@storybook/test-runner": "catalog:",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^13.4.0",
"@types/jest-image-snapshot": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
Expand Down
179 changes: 179 additions & 0 deletions packages/keychain/src/components/ExecutionContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ExecutionContainer } from "./ExecutionContainer";
import { describe, expect, beforeEach, it, vi } from "vitest";

const mockEstimateFee = vi.fn().mockImplementation(async () => ({
suggestedMaxFee: BigInt(1000),
}));

vi.mock("@/hooks/connection", () => ({
useConnection: () => ({
controller: {
estimateInvokeFee: mockEstimateFee,
},
upgrade: {
available: false,
inProgress: false,
error: null,
start: vi.fn(),
},
closeModal: vi.fn(),
openModal: vi.fn(),
logout: vi.fn(),
context: {},
origin: "https://test.com",
rpcUrl: "https://test.rpc.com",
chainId: "1",
chainName: "testnet",
policies: {},
theme: {},
hasPrefundRequest: false,
error: null,
setController: vi.fn(),
setContext: vi.fn(),
openSettings: vi.fn(),
}),
useChainId: () => "1",
}));

describe("ExecutionContainer", () => {
const defaultProps = {
transactions: [],
onSubmit: vi.fn(),
onError: vi.fn(),
children: <div>Test Content</div>,
title: "Test Title",
description: "Test Description",
};

beforeEach(() => {
vi.clearAllMocks();
});

it("renders basic content correctly", () => {
render(<ExecutionContainer {...defaultProps} />);
expect(screen.getByText("Test Content")).toBeInTheDocument();
expect(screen.getByText("SUBMIT")).toBeInTheDocument();
});

it("estimates fees when transactions are provided", async () => {
render(
<ExecutionContainer
{...defaultProps}
transactions={[{ some: "transaction" }]}
/>,
);

await waitFor(() => {
expect(mockEstimateFee).toHaveBeenCalled();
});
});

it("handles submit action correctly", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);

render(
<ExecutionContainer
{...defaultProps}
transactions={[{ some: "transaction" }]}
onSubmit={onSubmit}
/>,
);

// Wait for fee estimation to complete
await waitFor(() => {
expect(mockEstimateFee).toHaveBeenCalled();
});

// Wait for the fee to be displayed
await waitFor(() => {
expect(screen.getByText("<0.00001")).toBeInTheDocument();
});

const submitButton = screen.getByText("SUBMIT");
expect(submitButton).not.toBeDisabled();
fireEvent.click(submitButton);

await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
});

it("handles submit error correctly", async () => {
const onError = vi.fn();
const onSubmit = vi.fn().mockRejectedValue({
code: 113, // ErrorCode.InsufficientBalance,
message: "Insufficient balance",
});

render(
<ExecutionContainer
{...defaultProps}
transactions={[{ some: "transaction" }]}
onSubmit={onSubmit}
onError={onError}
/>,
);

// Wait for fee estimation to complete
await waitFor(() => {
expect(mockEstimateFee).toHaveBeenCalled();
});

// Wait for the fee to be displayed
await waitFor(() => {
expect(screen.getByText("<0.00001")).toBeInTheDocument();
});

const submitButton = screen.getByText("SUBMIT");
expect(submitButton).not.toBeDisabled();
fireEvent.click(submitButton);

await waitFor(() => {
expect(onError).toHaveBeenCalled();
});
});

it("shows deploy view when controller is not deployed", () => {
render(
<ExecutionContainer
{...defaultProps}
executionError={{
code: 112, // ErrorCode.CartridgeControllerNotDeployed,
message: "Controller not deployed",
}}
/>,
);

expect(screen.getByText("DEPLOY ACCOUNT")).toBeInTheDocument();
});

it("shows funding view when balance is insufficient", () => {
render(
<ExecutionContainer
{...defaultProps}
executionError={{
code: 113, // ErrorCode.InsufficientBalance,
message: "Insufficient balance",
}}
/>,
);

expect(screen.getByText("ADD FUNDS")).toBeInTheDocument();
});

it("shows continue button for already registered session", () => {
render(
<ExecutionContainer
{...defaultProps}
executionError={{
code: 132, // ErrorCode.SessionAlreadyRegistered,
message: "Session already registered",
}}
/>,
);

expect(screen.getByTestId("continue-button")).toBeInTheDocument();
expect(screen.getByText("Session Already Registered")).toBeInTheDocument();
});
});
23 changes: 12 additions & 11 deletions packages/keychain/src/components/ExecutionContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect } from "react";
import { Button } from "@cartridge/ui-next";
import { Container, Footer } from "@/components/layout";
import { useConnection } from "@/hooks/connection";
Expand Down Expand Up @@ -49,7 +49,6 @@ export function ExecutionContainer({
const [ctaState, setCTAState] = useState<"fund" | "deploy" | "execute">(
"execute",
);
const isEstimated = useRef(false);

const estimateFees = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -62,6 +61,7 @@ export function ExecutionContainer({
transactions,
transactionsDetail,
);
setCtrlError(undefined);
setMaxFee(est.suggestedMaxFee);
} catch (e) {
const error = parseControllerError(e as unknown as ControllerError);
Expand All @@ -73,21 +73,16 @@ export function ExecutionContainer({
);

useEffect(() => {
if (!!ctrlError || maxFee !== null || !transactions?.length) {
if (!transactions?.length) {
return;
}

const estimateFeesAsync = async () => {
if (isEstimated.current) {
return;
}

isEstimated.current = true;
await estimateFees(transactions, transactionsDetail);
};

estimateFeesAsync();
}, [ctrlError, maxFee, transactions, transactionsDetail, estimateFees]);
}, [transactions, transactionsDetail, estimateFees]);

useEffect(() => {
setCtrlError(executionError);
Expand Down Expand Up @@ -175,7 +170,11 @@ export function ExecutionContainer({
variant="info"
title="Session Already Registered"
/>
<Button onClick={() => onSubmit()} isLoading={false}>
<Button
onClick={() => onSubmit()}
isLoading={false}
data-testid="continue-button"
>
CONTINUE
</Button>
</>
Expand All @@ -189,7 +188,9 @@ export function ExecutionContainer({
onClick={handleSubmit}
isLoading={isLoading}
disabled={
!transactions || (maxFee === null && transactions?.length)
ctrlError ||
!transactions ||
(maxFee === null && transactions?.length)
}
>
{buttonText}
Expand Down
40 changes: 30 additions & 10 deletions packages/keychain/src/components/layout/container/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import { PropsWithChildren } from "react";
import { PropsWithChildren, useEffect, useState } from "react";
import { Header, HeaderProps } from "./header";

function useMediaQuery(query: string) {
const [matches, setMatches] = useState(false);

useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);

const listener = (e: MediaQueryListEvent) => {
setMatches(e.matches);
};

media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [query]);

return matches;
}

export function Container({
children,
onBack,
Expand Down Expand Up @@ -35,19 +53,21 @@ export function Container({
}

function ResponsiveWrapper({ children }: PropsWithChildren) {
return (
<>
{/* for desktop */}
<div className="hidden md:flex w-screen h-screen items-center justify-center">
const isDesktop = useMediaQuery("(min-width: 768px)");

if (isDesktop) {
return (
<div className="flex w-screen h-screen items-center justify-center">
<div className="w-desktop border border-muted rounded-xl flex flex-col relative overflow-hidden align-middle">
{children}
</div>
</div>
);
}

{/* device smaller than desktop width */}
<div className="md:hidden w-screen h-screen max-w-desktop relative flex flex-col bg-background">
{children}
</div>
</>
return (
<div className="w-screen h-screen max-w-desktop relative flex flex-col bg-background">
{children}
</div>
);
}
30 changes: 30 additions & 0 deletions packages/keychain/src/test/mocks/connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { vi } from "vitest";

export const mockController = {
estimateInvokeFee: vi.fn(),
};

export const mockConnection = {
controller: mockController,
upgrade: {
available: false,
inProgress: false,
error: null,
start: vi.fn(),
},
closeModal: vi.fn(),
openModal: vi.fn(),
logout: vi.fn(),
context: {},
origin: "https://test.com",
rpcUrl: "https://test.rpc.com",
chainId: "1",
chainName: "testnet",
policies: {},
theme: {},
hasPrefundRequest: false,
error: null,
setController: vi.fn(),
setContext: vi.fn(),
openSettings: vi.fn(),
};
24 changes: 24 additions & 0 deletions packages/keychain/src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import "@testing-library/jest-dom";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";

// Add any global test setup here

// Mock matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false, // Default to mobile view
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

afterEach(() => {
cleanup();
});
1 change: 1 addition & 0 deletions packages/keychain/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@cartridge/tsconfig/react.json",
"compilerOptions": {
"types": ["vitest/globals"],
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
Expand Down
4 changes: 4 additions & 0 deletions packages/keychain/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,8 @@ export default defineConfig(({ mode }) => ({
define: {
global: "globalThis",
},
test: {
environment: "jsdom",
globals: true,
},
}));
Loading
Loading