diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts new file mode 100644 index 0000000..d17700a --- /dev/null +++ b/src/__tests__/client.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +class TestFailError extends Error { + constructor(message: string) { + super(message); + this.name = "FailError"; + } +} + +const contextClientCtor = mock((options: unknown) => options); + +class MockContextClient { + constructor(public readonly options: unknown) { + contextClientCtor(options); + } +} + +const loadConfig = mock((): Record => ({})); +const fail = mock((message: string) => { + throw new TestFailError(message); +}); + +mock.module("context-markets", () => ({ + ContextClient: MockContextClient, +})); + +mock.module("../config.js", () => ({ + loadConfig, +})); + +mock.module("../format.js", () => ({ + fail, +})); + +const { readClient, tradingClient } = await import("../client.js"); + +const ORIGINAL_ENV = { + CONTEXT_API_KEY: process.env.CONTEXT_API_KEY, + CONTEXT_PRIVATE_KEY: process.env.CONTEXT_PRIVATE_KEY, + CONTEXT_RPC_URL: process.env.CONTEXT_RPC_URL, + CONTEXT_BASE_URL: process.env.CONTEXT_BASE_URL, + CONTEXT_CHAIN: process.env.CONTEXT_CHAIN, +}; + +function clearContextEnv(): void { + delete process.env.CONTEXT_API_KEY; + delete process.env.CONTEXT_PRIVATE_KEY; + delete process.env.CONTEXT_RPC_URL; + delete process.env.CONTEXT_BASE_URL; + delete process.env.CONTEXT_CHAIN; +} + +function restoreEnv(): void { + clearContextEnv(); + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value !== undefined) { + process.env[key] = value; + } + } +} + +describe("client", () => { + beforeEach(() => { + clearContextEnv(); + contextClientCtor.mockClear(); + loadConfig.mockClear(); + loadConfig.mockReturnValue({}); + fail.mockClear(); + }); + + afterEach(() => { + restoreEnv(); + }); + + test("readClient uses the api key from flags", () => { + const client = readClient({ + "api-key": "flag-api-key", + "rpc-url": "https://rpc.example", + "base-url": "https://api.example", + chain: "testnet", + }); + + expect(client).toBeInstanceOf(MockContextClient); + expect(contextClientCtor).toHaveBeenCalledWith({ + apiKey: "flag-api-key", + rpcUrl: "https://rpc.example", + baseUrl: "https://api.example", + chain: "testnet", + }); + }); + + test("readClient throws when no api key can be resolved", () => { + expect(() => readClient()).toThrow(TestFailError); + expect(fail).toHaveBeenCalledWith( + "Missing CONTEXT_API_KEY. Set via --api-key flag, CONTEXT_API_KEY env, or ~/.config/context/config.env", + ); + }); + + test("tradingClient uses api key and private key from flags", () => { + const client = tradingClient({ + "api-key": "flag-api-key", + "private-key": "0xabc123", + "rpc-url": "https://rpc.example", + "base-url": "https://api.example", + chain: "mainnet", + }); + + expect(client).toBeInstanceOf(MockContextClient); + expect(contextClientCtor).toHaveBeenCalledWith({ + apiKey: "flag-api-key", + rpcUrl: "https://rpc.example", + baseUrl: "https://api.example", + chain: "mainnet", + signer: { + privateKey: "0xabc123", + }, + }); + }); + + test("tradingClient throws when the private key is missing", () => { + process.env.CONTEXT_API_KEY = "env-api-key"; + + expect(() => tradingClient()).toThrow(TestFailError); + expect(fail).toHaveBeenCalledWith( + "A private key is required for trading operations.", + { + hint: + "Set CONTEXT_PRIVATE_KEY env var, pass --private-key , or run `context setup`", + }, + ); + }); + + test("falls back to the config file for credentials and chain settings", () => { + loadConfig.mockReturnValue({ + CONTEXT_API_KEY: "config-api-key", + CONTEXT_PRIVATE_KEY: "0xconfig-private-key", + CONTEXT_RPC_URL: "https://config-rpc.example", + CONTEXT_BASE_URL: "https://config-api.example", + CONTEXT_CHAIN: "testnet", + }); + + tradingClient(); + + expect(contextClientCtor).toHaveBeenCalledWith({ + apiKey: "config-api-key", + rpcUrl: "https://config-rpc.example", + baseUrl: "https://config-api.example", + chain: "testnet", + signer: { + privateKey: "0xconfig-private-key", + }, + }); + }); +}); diff --git a/src/__tests__/commands/account.test.ts b/src/__tests__/commands/account.test.ts new file mode 100644 index 0000000..03b3c7b --- /dev/null +++ b/src/__tests__/commands/account.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { FailError, commandHarness } from "../helpers.js"; + +const { default: handleAccount } = await import("../../commands/account.js"); + +describe("account commands", () => { + beforeEach(() => { + commandHarness.reset(); + }); + + test("status fetches account status and portfolio balance in parallel", async () => { + const status = { + address: "0xabc", + ethBalance: "1000000000000000000", + usdcAllowance: true, + isOperatorApproved: true, + isReady: true, + }; + const balance = { + usdc: { + balance: 20_000_000, + settlementBalance: 8_000_000, + walletBalance: 12_000_000, + }, + }; + commandHarness.client.account.status.mockResolvedValue(status); + commandHarness.client.portfolio.balance.mockResolvedValue(balance); + + await commandHarness.callCommand(handleAccount, { + subcommand: "status", + }); + + expect(commandHarness.tradingClient).toHaveBeenCalled(); + expect(commandHarness.client.account.status).toHaveBeenCalled(); + expect(commandHarness.client.portfolio.balance).toHaveBeenCalledWith(); + expect(commandHarness.outCalls[0]?.data).toEqual(status); + }); + + test("setup calls ctx.account.setup", async () => { + const result = { success: true }; + commandHarness.client.account.setup.mockResolvedValue(result); + + await commandHarness.callCommand(handleAccount, { + subcommand: "setup", + }); + + expect(commandHarness.client.account.setup).toHaveBeenCalled(); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("mint-test-usdc passes an optional amount", async () => { + const result = { amount: 250, txHash: "0xabc" }; + commandHarness.client.account.mintTestUsdc.mockResolvedValue(result); + + await commandHarness.callCommand(handleAccount, { + subcommand: "mint-test-usdc", + flags: { + amount: "250", + }, + }); + + expect(commandHarness.client.account.mintTestUsdc).toHaveBeenCalledWith(250); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("deposit parses the amount and reshapes the response", async () => { + commandHarness.client.account.deposit.mockResolvedValue({ + txHash: "0xdeposit", + }); + + await commandHarness.callCommand(handleAccount, { + subcommand: "deposit", + positional: ["100.5"], + }); + + expect(commandHarness.client.account.deposit).toHaveBeenCalledWith(100.5); + expect(commandHarness.outCalls[0]?.data).toEqual({ + status: "deposited", + amount_usdc: 100.5, + tx_hash: "0xdeposit", + }); + }); + + test("withdraw parses the amount and reshapes the response", async () => { + commandHarness.client.account.withdraw.mockResolvedValue("0xwithdraw"); + + await commandHarness.callCommand(handleAccount, { + subcommand: "withdraw", + positional: ["25"], + }); + + expect(commandHarness.client.account.withdraw).toHaveBeenCalledWith(25); + expect(commandHarness.outCalls[0]?.data).toEqual({ + status: "withdrawn", + amount_usdc: 25, + tx_hash: "0xwithdraw", + }); + }); + + test("mint-complete-sets calls ctx.account.mintCompleteSets", async () => { + commandHarness.client.account.mintCompleteSets.mockResolvedValue("0xmint"); + + await commandHarness.callCommand(handleAccount, { + subcommand: "mint-complete-sets", + positional: ["market-1", "5"], + }); + + expect(commandHarness.client.account.mintCompleteSets).toHaveBeenCalledWith( + "market-1", + 5, + ); + expect(commandHarness.outCalls[0]?.data).toEqual({ + status: "minted", + market_id: "market-1", + amount: 5, + tx_hash: "0xmint", + }); + }); + + test("burn-complete-sets honors --credit-internal=false", async () => { + commandHarness.client.account.burnCompleteSets.mockResolvedValue("0xburn"); + + await commandHarness.callCommand(handleAccount, { + subcommand: "burn-complete-sets", + positional: ["market-1", "3"], + flags: { + "credit-internal": "false", + }, + }); + + expect(commandHarness.client.account.burnCompleteSets).toHaveBeenCalledWith( + "market-1", + 3, + false, + ); + expect(commandHarness.outCalls[0]?.data).toEqual({ + status: "burned", + market_id: "market-1", + amount: 3, + credit_internal: false, + tx_hash: "0xburn", + }); + }); + + test("gasless-approve calls ctx.account.gaslessSetup", async () => { + const result = { success: true }; + commandHarness.client.account.gaslessSetup.mockResolvedValue(result); + + await commandHarness.callCommand(handleAccount, { + subcommand: "gasless-approve", + }); + + expect(commandHarness.client.account.gaslessSetup).toHaveBeenCalled(); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("gasless-deposit parses the amount", async () => { + const result = { success: true, txHash: "0xgasless" }; + commandHarness.client.account.gaslessDeposit.mockResolvedValue(result); + + await commandHarness.callCommand(handleAccount, { + subcommand: "gasless-deposit", + positional: ["15"], + }); + + expect(commandHarness.client.account.gaslessDeposit).toHaveBeenCalledWith(15); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("invalid deposit amounts throw FailError", async () => { + await expect( + commandHarness.callCommand(handleAccount, { + subcommand: "deposit", + positional: ["0"], + }), + ).rejects.toBeInstanceOf(FailError); + + expect(commandHarness.failCalls[0]).toEqual({ + message: "Deposit amount must be a positive number", + details: { received: "0" }, + }); + }); + + test("help does not call the sdk", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await commandHarness.callCommand(handleAccount, { + subcommand: "help", + }); + + expect(commandHarness.tradingClient).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalled(); + logSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/commands/markets.test.ts b/src/__tests__/commands/markets.test.ts new file mode 100644 index 0000000..15ecd68 --- /dev/null +++ b/src/__tests__/commands/markets.test.ts @@ -0,0 +1,404 @@ +import { beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { FailError, commandHarness } from "../helpers.js"; + +const { default: handleMarkets } = await import("../../commands/markets.js"); + +describe("markets commands", () => { + beforeEach(() => { + commandHarness.reset(); + }); + + test("list calls ctx.markets.list with parsed filters", async () => { + const result = { + markets: [ + { + id: "market-1", + shortQuestion: "Will it rain?", + outcomePrices: [{ buyPrice: 450000 }, { buyPrice: 550000 }], + volume: 1_000_000, + status: "active", + }, + ], + cursor: "cursor-2", + }; + commandHarness.client.markets.list.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "list", + flags: { + status: "active", + "sort-by": "volume", + sort: "desc", + limit: "5", + cursor: "cursor-1", + visibility: "visible", + "resolution-status": "unresolved", + creator: "0xabc", + category: "weather", + }, + }); + + expect(commandHarness.client.markets.list).toHaveBeenCalledWith({ + status: "active", + sortBy: "volume", + sort: "desc", + limit: 5, + cursor: "cursor-1", + visibility: "visible", + resolutionStatus: "unresolved", + creator: "0xabc", + category: "weather", + }); + expect(commandHarness.outCalls[0]).toEqual( + expect.objectContaining({ + data: result, + config: expect.objectContaining({ + numbered: true, + emptyMessage: "No markets found.", + cursor: "cursor-2", + }), + }), + ); + }); + + test("search passes the query, limit, and offset", async () => { + const result = { markets: [{ id: "market-1", shortQuestion: "Election" }] }; + commandHarness.client.markets.search.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "search", + positional: ["election"], + flags: { + limit: "10", + offset: "20", + }, + }); + + expect(commandHarness.client.markets.search).toHaveBeenCalledWith({ + q: "election", + limit: 10, + offset: 20, + }); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("get fetches a market by id", async () => { + const market = { + id: "market-1", + question: "Will it rain?", + status: "active", + }; + commandHarness.client.markets.get.mockResolvedValue(market); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "get", + positional: ["market-1"], + }); + + expect(commandHarness.client.markets.get).toHaveBeenCalledWith("market-1"); + expect(commandHarness.outCalls[0]).toEqual( + expect.objectContaining({ + data: market, + config: expect.objectContaining({ + detail: expect.arrayContaining([["ID", "market-1"]]), + }), + }), + ); + }); + + test("quotes fetches quotes for a market", async () => { + const result = { + marketId: "market-1", + yes: { bid: 510000, ask: 530000, last: 520000 }, + no: { bid: 470000, ask: 490000, last: 480000 }, + spread: 20000, + }; + commandHarness.client.markets.quotes.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "quotes", + positional: ["market-1"], + }); + + expect(commandHarness.client.markets.quotes).toHaveBeenCalledWith("market-1"); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("link emits structured output in json mode", async () => { + commandHarness.client.markets.get.mockResolvedValue({ + id: "market-1", + slug: "will-it-rain", + question: "Will it rain?", + }); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "link", + positional: ["market-1"], + }); + + expect(commandHarness.outCalls[0]?.data).toEqual({ + url: "https://context.markets/markets/will-it-rain", + marketId: "market-1", + question: "Will it rain?", + }); + }); + + test("link prints human-readable output in table mode", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + commandHarness.setOutputMode("table"); + commandHarness.client.markets.get.mockResolvedValue({ + id: "market-1", + slug: "will-it-rain", + question: "Will it rain?", + }); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "link", + positional: ["market-1"], + }); + + expect(commandHarness.outCalls).toHaveLength(0); + expect(logSpy).toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + test("orderbook calls fullOrderbook with depth in json mode", async () => { + const result = { + marketId: "market-1", + yes: { bids: [{ price: 510000, size: 12 }], asks: [{ price: 530000, size: 9 }] }, + no: { bids: [{ price: 470000, size: 8 }], asks: [{ price: 490000, size: 10 }] }, + }; + commandHarness.client.markets.fullOrderbook.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "orderbook", + positional: ["market-1"], + flags: { + depth: "15", + }, + }); + + expect(commandHarness.client.markets.fullOrderbook).toHaveBeenCalledWith( + "market-1", + { depth: 15 }, + ); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("orderbook renders console output in table mode", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + commandHarness.setOutputMode("table"); + commandHarness.client.markets.fullOrderbook.mockResolvedValue({ + marketId: "market-1", + yes: { bids: [], asks: [] }, + no: { bids: [], asks: [] }, + }); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "orderbook", + positional: ["market-1"], + }); + + expect(commandHarness.outCalls).toHaveLength(0); + expect(logSpy).toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + test("simulate validates flags and calls ctx.markets.simulate", async () => { + const result = { + marketId: "market-1", + side: "yes", + amount: 25, + estimatedContracts: 52, + estimatedAvgPrice: 480000, + estimatedSlippage: 0.02, + }; + commandHarness.client.markets.simulate.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "simulate", + positional: ["market-1"], + flags: { + side: "yes", + amount: "25", + "amount-type": "contracts", + trader: "0xabc", + }, + }); + + expect(commandHarness.client.markets.simulate).toHaveBeenCalledWith( + "market-1", + { + side: "yes", + amount: 25, + amountType: "contracts", + trader: "0xabc", + }, + ); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("price-history passes the timeframe flag", async () => { + const result = [{ timestamp: "2026-03-21T00:00:00Z", price: 510000 }]; + commandHarness.client.markets.priceHistory.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "price-history", + positional: ["market-1"], + flags: { + timeframe: "1d", + }, + }); + + expect(commandHarness.client.markets.priceHistory).toHaveBeenCalledWith( + "market-1", + { timeframe: "1d" }, + ); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("oracle fetches oracle info", async () => { + const result = { marketId: "market-1", probability: 0.61 }; + commandHarness.client.markets.oracle.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "oracle", + positional: ["market-1"], + }); + + expect(commandHarness.client.markets.oracle).toHaveBeenCalledWith("market-1"); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("oracle-quotes fetches oracle quote history", async () => { + const result = { quotes: [{ probability: 0.61 }] }; + commandHarness.client.markets.oracleQuotes.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "oracle-quotes", + positional: ["market-1"], + }); + + expect(commandHarness.client.markets.oracleQuotes).toHaveBeenCalledWith( + "market-1", + ); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("request-oracle-quote asks the SDK to create a fresh quote", async () => { + const result = { requested: true }; + commandHarness.client.markets.requestOracleQuote.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "request-oracle-quote", + positional: ["market-1"], + }); + + expect(commandHarness.client.markets.requestOracleQuote).toHaveBeenCalledWith( + "market-1", + ); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("activity passes limit and cursor", async () => { + const result = { + activity: [{ type: "trade", timestamp: "2026-03-21T00:00:00Z" }], + pagination: { cursor: "next-cursor" }, + }; + commandHarness.client.markets.activity.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "activity", + positional: ["market-1"], + flags: { + limit: "25", + cursor: "cursor-1", + }, + }); + + expect(commandHarness.client.markets.activity).toHaveBeenCalledWith( + "market-1", + { + limit: 25, + cursor: "cursor-1", + }, + ); + expect(commandHarness.outCalls[0]).toEqual( + expect.objectContaining({ + data: result, + config: expect.objectContaining({ + cursor: "next-cursor", + }), + }), + ); + }); + + test("global-activity passes limit and cursor", async () => { + const result = { + activity: [{ type: "trade", timestamp: "2026-03-21T00:00:00Z" }], + pagination: { cursor: "next-cursor" }, + }; + commandHarness.client.markets.globalActivity.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "global-activity", + flags: { + limit: "50", + cursor: "cursor-1", + }, + }); + + expect(commandHarness.client.markets.globalActivity).toHaveBeenCalledWith({ + limit: 50, + cursor: "cursor-1", + }); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("create uses the trading client", async () => { + const result = { marketId: "market-1", txHash: "0xabc" }; + commandHarness.client.markets.create.mockResolvedValue(result); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "create", + positional: ["question-1"], + flags: { + "api-key": "api-key", + "private-key": "0xprivate", + }, + }); + + expect(commandHarness.tradingClient).toHaveBeenCalledWith({ + "api-key": "api-key", + "private-key": "0xprivate", + }); + expect(commandHarness.client.markets.create).toHaveBeenCalledWith("question-1"); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("missing required arguments throw FailError", async () => { + await expect( + commandHarness.callCommand(handleMarkets, { + subcommand: "search", + }), + ).rejects.toBeInstanceOf(FailError); + + expect(commandHarness.failCalls[0]).toEqual({ + message: "Missing required argument: ", + details: { usage: "context markets search " }, + }); + }); + + test("help does not call the sdk", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await commandHarness.callCommand(handleMarkets, { + subcommand: "help", + }); + + expect(commandHarness.readClient).not.toHaveBeenCalled(); + expect(commandHarness.tradingClient).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalled(); + logSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/commands/orders.test.ts b/src/__tests__/commands/orders.test.ts new file mode 100644 index 0000000..634bb67 --- /dev/null +++ b/src/__tests__/commands/orders.test.ts @@ -0,0 +1,445 @@ +import { beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { FailError, commandHarness } from "../helpers.js"; + +const { default: handleOrders } = await import("../../commands/orders.js"); + +describe("orders commands", () => { + beforeEach(() => { + commandHarness.reset(); + }); + + test("list uses the read client when --trader is provided", async () => { + const result = { + orders: [{ nonce: "0x1", marketId: "market-1" }], + cursor: "cursor-2", + }; + commandHarness.client.orders.list.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "list", + flags: { + trader: "0xabc", + market: "market-1", + status: "open", + cursor: "cursor-1", + limit: "25", + }, + }); + + expect(commandHarness.readClient).toHaveBeenCalledWith({ + trader: "0xabc", + market: "market-1", + status: "open", + cursor: "cursor-1", + limit: "25", + }); + expect(commandHarness.client.orders.list).toHaveBeenCalledWith({ + trader: "0xabc", + marketId: "market-1", + status: "open", + cursor: "cursor-1", + limit: 25, + }); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("mine calls ctx.orders.mine with an optional market filter", async () => { + const result = { orders: [{ nonce: "0x1" }], cursor: "cursor-1" }; + commandHarness.client.orders.mine.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "mine", + flags: { + market: "market-1", + }, + }); + + expect(commandHarness.tradingClient).toHaveBeenCalled(); + expect(commandHarness.client.orders.mine).toHaveBeenCalledWith("market-1"); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("get fetches an order by id", async () => { + const result = { + nonce: "0x1", + marketId: "market-1", + status: "open", + side: "buy", + outcomeIndex: 1, + }; + commandHarness.client.orders.get.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "get", + positional: ["0x1"], + }); + + expect(commandHarness.client.orders.get).toHaveBeenCalledWith("0x1"); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("recent passes filters through to ctx.orders.recent", async () => { + const result = { orders: [{ nonce: "0x1" }] }; + commandHarness.client.orders.recent.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "recent", + flags: { + trader: "0xabc", + market: "market-1", + status: "filled", + limit: "10", + "window-seconds": "300", + }, + }); + + expect(commandHarness.client.orders.recent).toHaveBeenCalledWith({ + trader: "0xabc", + marketId: "market-1", + status: "filled", + limit: 10, + windowSeconds: 300, + }); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("simulate validates and calls ctx.orders.simulate", async () => { + const result = { matchedSize: 8 }; + commandHarness.client.orders.simulate.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "simulate", + flags: { + market: "market-1", + trader: "0xabc", + size: "10", + price: "45", + outcome: "no", + side: "ask", + }, + }); + + expect(commandHarness.client.orders.simulate).toHaveBeenCalledWith({ + marketId: "market-1", + trader: "0xabc", + maxSize: "10", + maxPrice: "45", + outcomeIndex: 0, + side: "ask", + }); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("create validates, confirms, and places a limit order", async () => { + const result = { + success: true, + order: { + nonce: "0x1", + marketId: "market-1", + type: "limit", + status: "open", + percentFilled: 0, + }, + }; + commandHarness.client.orders.create.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "create", + flags: { + market: "market-1", + outcome: "yes", + side: "buy", + price: "42", + size: "10", + "expiry-seconds": "3600", + "inventory-mode": "mint", + "maker-role": "taker", + }, + }); + + expect(commandHarness.orderPromptCalls[0]).toEqual({ + summary: { + market: "market-1", + side: "buy", + outcome: "yes", + price: "42¢", + size: "10", + estimatedCost: "$4.20 USDC", + }, + flags: { + market: "market-1", + outcome: "yes", + side: "buy", + price: "42", + size: "10", + "expiry-seconds": "3600", + "inventory-mode": "mint", + "maker-role": "taker", + }, + }); + expect(commandHarness.client.orders.create).toHaveBeenCalledWith({ + marketId: "market-1", + outcome: "yes", + side: "buy", + priceCents: 42, + size: 10, + expirySeconds: 3600, + inventoryModeConstraint: 2, + makerRoleConstraint: 2, + }); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("market confirms and places a market order", async () => { + const result = { + success: true, + order: { + nonce: "0x2", + marketId: "market-1", + type: "market", + status: "filled", + percentFilled: 100, + }, + }; + commandHarness.client.orders.createMarket.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "market", + flags: { + market: "market-1", + outcome: "no", + side: "sell", + "max-price": "55", + "max-size": "12", + "expiry-seconds": "120", + }, + }); + + expect(commandHarness.orderPromptCalls[0]).toEqual({ + summary: { + market: "market-1", + side: "sell", + outcome: "no", + price: "max 55¢", + size: "max 12", + estimatedCost: "up to $6.60 USDC", + }, + flags: { + market: "market-1", + outcome: "no", + side: "sell", + "max-price": "55", + "max-size": "12", + "expiry-seconds": "120", + }, + }); + expect(commandHarness.client.orders.createMarket).toHaveBeenCalledWith({ + marketId: "market-1", + outcome: "no", + side: "sell", + maxPriceCents: 55, + maxSize: 12, + expirySeconds: 120, + }); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("cancel confirms and cancels the nonce", async () => { + const result = { success: true, alreadyCancelled: false }; + commandHarness.client.orders.cancel.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "cancel", + positional: ["0xdeadbeef"], + }); + + expect(commandHarness.actionPromptCalls[0]).toEqual({ + message: "Cancel order 0xdeadbeef?", + flags: {}, + }); + expect(commandHarness.client.orders.cancel).toHaveBeenCalledWith("0xdeadbeef"); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("cancel-replace confirms and sends both nonce and replacement order", async () => { + const result = { + cancel: { success: true }, + create: { success: true, order: { nonce: "0x2" } }, + }; + commandHarness.client.orders.cancelReplace.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "cancel-replace", + positional: ["0x1"], + flags: { + market: "market-1", + outcome: "yes", + side: "buy", + price: "40", + size: "8", + }, + }); + + expect(commandHarness.actionPromptCalls[0]).toEqual({ + message: "Cancel order 0x1 and place replacement?", + flags: { + market: "market-1", + outcome: "yes", + side: "buy", + price: "40", + size: "8", + }, + }); + expect(commandHarness.client.orders.cancelReplace).toHaveBeenCalledWith( + "0x1", + { + marketId: "market-1", + outcome: "yes", + side: "buy", + priceCents: 40, + size: 8, + expirySeconds: undefined, + inventoryModeConstraint: undefined, + makerRoleConstraint: undefined, + }, + ); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("bulk-create parses json and calls ctx.orders.bulkCreate", async () => { + const orders = [ + { + marketId: "market-1", + outcome: "yes", + side: "buy", + priceCents: 50, + size: 10, + }, + ]; + const result = { success: true }; + commandHarness.client.orders.bulkCreate.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "bulk-create", + flags: { + orders: JSON.stringify(orders), + }, + }); + + expect(commandHarness.client.orders.bulkCreate).toHaveBeenCalledWith(orders); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("bulk-cancel parses comma-separated nonces", async () => { + const result = { success: true }; + commandHarness.client.orders.bulkCancel.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "bulk-cancel", + flags: { + nonces: "0x1, 0x2", + }, + }); + + expect(commandHarness.client.orders.bulkCancel).toHaveBeenCalledWith([ + "0x1", + "0x2", + ]); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("bulk parses creates and cancels together", async () => { + const creates = [{ marketId: "market-1", outcome: "yes" }]; + const result = { success: true }; + commandHarness.client.orders.bulk.mockResolvedValue(result); + + await commandHarness.callCommand(handleOrders, { + subcommand: "bulk", + flags: { + creates: JSON.stringify(creates), + cancels: "0x1,0x2", + }, + }); + + expect(commandHarness.client.orders.bulk).toHaveBeenCalledWith(creates, [ + "0x1", + "0x2", + ]); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("missing required flags throw FailError", async () => { + await expect( + commandHarness.callCommand(handleOrders, { + subcommand: "create", + flags: { + outcome: "yes", + side: "buy", + price: "42", + size: "10", + }, + }), + ).rejects.toBeInstanceOf(FailError); + + expect(commandHarness.failCalls[0]).toEqual({ + message: "Missing required flag: --market", + details: { + usage: + "context orders create --market --outcome --side --price <1-99> --size ", + }, + }); + }); + + test("invalid prices throw FailError", async () => { + await expect( + commandHarness.callCommand(handleOrders, { + subcommand: "create", + flags: { + market: "market-1", + outcome: "yes", + side: "buy", + price: "0", + size: "10", + }, + }), + ).rejects.toBeInstanceOf(FailError); + + expect(commandHarness.failCalls[0]).toEqual({ + message: "--price must be between 1 and 99 (cents)", + details: { received: "0" }, + }); + }); + + test("invalid sides throw FailError", async () => { + await expect( + commandHarness.callCommand(handleOrders, { + subcommand: "create", + flags: { + market: "market-1", + outcome: "yes", + side: "hold", + price: "50", + size: "10", + }, + }), + ).rejects.toBeInstanceOf(FailError); + + expect(commandHarness.failCalls[0]).toEqual({ + message: "--side must be 'buy' or 'sell'", + details: { received: "hold" }, + }); + }); + + test("help does not call the sdk", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await commandHarness.callCommand(handleOrders, { + subcommand: "help", + }); + + expect(commandHarness.readClient).not.toHaveBeenCalled(); + expect(commandHarness.tradingClient).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalled(); + logSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/commands/portfolio.test.ts b/src/__tests__/commands/portfolio.test.ts new file mode 100644 index 0000000..f3795c7 --- /dev/null +++ b/src/__tests__/commands/portfolio.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { commandHarness } from "../helpers.js"; + +const { default: handlePortfolio } = await import("../../commands/portfolio.js"); + +describe("portfolio commands", () => { + beforeEach(() => { + commandHarness.reset(); + }); + + test("overview combines balance, stats, and positions in json mode", async () => { + const balance = { + usdc: { + balance: 15_000_000, + settlementBalance: 5_000_000, + walletBalance: 10_000_000, + }, + }; + const stats = { realizedPnl: 1_500_000, winRate: 0.55 }; + const positions = { + portfolio: [{ marketId: "market-1", outcomeName: "YES", balance: 10 }], + }; + commandHarness.client.portfolio.balance.mockResolvedValue(balance); + commandHarness.client.portfolio.stats.mockResolvedValue(stats); + commandHarness.client.portfolio.get.mockResolvedValue(positions); + + await commandHarness.callCommand(handlePortfolio, { + subcommand: "overview", + flags: { + address: "0xabc", + }, + }); + + expect(commandHarness.readClient).toHaveBeenCalledWith({ address: "0xabc" }); + expect(commandHarness.client.portfolio.balance).toHaveBeenCalledWith("0xabc"); + expect(commandHarness.client.portfolio.stats).toHaveBeenCalledWith("0xabc"); + expect(commandHarness.client.portfolio.get).toHaveBeenCalledWith("0xabc", { + kind: "active", + pageSize: 10, + }); + expect(commandHarness.outCalls[0]?.data).toEqual({ + balance, + stats, + activePositions: positions.portfolio, + }); + }); + + test("overview renders console output in table mode", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + commandHarness.setOutputMode("table"); + commandHarness.client.portfolio.balance.mockResolvedValue({ + usdc: { + balance: 20_000_000, + settlementBalance: 8_000_000, + walletBalance: 12_000_000, + }, + }); + commandHarness.client.portfolio.stats.mockResolvedValue({ + realizedPnl: 2_000_000, + }); + commandHarness.client.portfolio.get.mockResolvedValue({ + portfolio: [{ marketId: "market-1", outcomeName: "YES", balance: 10 }], + }); + + await commandHarness.callCommand(handlePortfolio, { + subcommand: "overview", + }); + + expect(commandHarness.tradingClient).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalled(); + expect(commandHarness.outCalls).toHaveLength(1); + logSpy.mockRestore(); + }); + + test("get passes kind, market, cursor, and page size", async () => { + const result = { + portfolio: [{ marketId: "market-1", outcomeName: "YES" }], + cursor: "cursor-2", + }; + commandHarness.client.portfolio.get.mockResolvedValue(result); + + await commandHarness.callCommand(handlePortfolio, { + subcommand: "get", + flags: { + address: "0xabc", + kind: "claimable", + market: "market-1", + cursor: "cursor-1", + "page-size": "15", + }, + }); + + expect(commandHarness.client.portfolio.get).toHaveBeenCalledWith("0xabc", { + kind: "claimable", + marketId: "market-1", + cursor: "cursor-1", + pageSize: 15, + }); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("claimable calls ctx.portfolio.claimable", async () => { + const result = { + totalClaimable: 3_000_000, + positions: [{ marketId: "market-1" }], + }; + commandHarness.client.portfolio.claimable.mockResolvedValue(result); + + await commandHarness.callCommand(handlePortfolio, { + subcommand: "claimable", + }); + + expect(commandHarness.client.portfolio.claimable).toHaveBeenCalledWith(undefined); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("stats outputs the raw stats payload", async () => { + const result = { realizedPnl: 1_000_000, unrealizedPnl: 500_000 }; + commandHarness.client.portfolio.stats.mockResolvedValue(result); + + await commandHarness.callCommand(handlePortfolio, { + subcommand: "stats", + flags: { + address: "0xabc", + }, + }); + + expect(commandHarness.client.portfolio.stats).toHaveBeenCalledWith("0xabc"); + expect(commandHarness.outCalls[0]).toEqual({ data: result, config: undefined }); + }); + + test("balance uses the trading client when no address is provided", async () => { + const result = { + address: "0xabc", + usdc: { + balance: 10_000_000, + settlementBalance: 4_000_000, + walletBalance: 6_000_000, + }, + }; + commandHarness.client.portfolio.balance.mockResolvedValue(result); + + await commandHarness.callCommand(handlePortfolio, { + subcommand: "balance", + }); + + expect(commandHarness.tradingClient).toHaveBeenCalled(); + expect(commandHarness.client.portfolio.balance).toHaveBeenCalledWith(undefined); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("token-balance calls ctx.portfolio.tokenBalance with both addresses", async () => { + const result = { + address: "0xabc", + tokenAddress: "0xdef", + balance: "1000000", + }; + commandHarness.client.portfolio.tokenBalance.mockResolvedValue(result); + + await commandHarness.callCommand(handlePortfolio, { + subcommand: "token-balance", + positional: ["0xabc", "0xdef"], + }); + + expect(commandHarness.readClient).toHaveBeenCalledWith({}); + expect(commandHarness.client.portfolio.tokenBalance).toHaveBeenCalledWith( + "0xabc", + "0xdef", + ); + expect(commandHarness.outCalls[0]?.data).toEqual(result); + }); + + test("help does not call the sdk", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await commandHarness.callCommand(handlePortfolio, { + subcommand: "help", + }); + + expect(commandHarness.readClient).not.toHaveBeenCalled(); + expect(commandHarness.tradingClient).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalled(); + logSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts new file mode 100644 index 0000000..5022e78 --- /dev/null +++ b/src/__tests__/errors.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { cleanErrorMessage } from "../error-format.js"; + +function standardRevertHex(reasonHex: string): string { + return `0x08c379a0${"0".repeat(128)}${reasonHex}`; +} + +describe("cleanErrorMessage", () => { + test("extracts the field name from zod-style validation errors", () => { + expect( + cleanErrorMessage("✖ Too big: expected number to be <=50\n → at limit"), + ).toBe("Invalid --limit: Too big: expected number to be <=50"); + }); + + test("decodes known EVM revert reasons", () => { + const revertHex = standardRevertHex( + "5452414e534645525f46524f4d5f4641494c4544", + ); + + expect( + cleanErrorMessage(`execution reverted for call with reason: ${revertHex}`), + ).toBe("USDC transfer failed — insufficient wallet balance"); + }); + + test("extracts the Details line from verbose viem errors", () => { + expect( + cleanErrorMessage( + [ + "TransactionExecutionError: Something failed", + "Details: operator approval failed", + "Docs: https://viem.sh", + "Version: viem@2.47.6", + ].join("\n"), + ), + ).toBe("operator approval failed"); + }); + + test("replaces insufficient funds errors with an actionable message", () => { + expect( + cleanErrorMessage( + "The total cost exceeds the balance of the account while estimating gas", + ), + ).toBe( + "Insufficient ETH for gas. Fund your wallet with testnet ETH on Base Sepolia.", + ); + }); + + test("decodes user operation reverts when revert data is present", () => { + const revertHex = standardRevertHex( + "494e53554646494349454e545f42414c414e4345", + ); + + expect( + cleanErrorMessage(`UserOperation reverted during execution: ${revertHex}`), + ).toBe("Insufficient balance"); + }); +}); diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts new file mode 100644 index 0000000..98194a2 --- /dev/null +++ b/src/__tests__/helpers.ts @@ -0,0 +1,327 @@ +import { mock, type Mock } from "bun:test"; +import type { ParsedArgs } from "../format.js"; + +export class FailError extends Error { + constructor(message: string) { + super(message); + this.name = "FailError"; + } +} + +export class CancelError extends Error { + constructor() { + super("Cancelled."); + this.name = "CancelError"; + } +} + +export interface OutCall { + data: unknown; + config?: unknown; +} + +export interface FailCall { + message: string; + details?: unknown; +} + +export interface OrderPromptSummary { + market?: string; + side: string; + outcome: string; + price?: string; + size?: string; + estimatedCost?: string; +} + +export interface OrderPromptCall { + summary: OrderPromptSummary; + flags: Record; +} + +export interface ActionPromptCall { + message: string; + flags: Record; +} + +type AsyncMethodMock = Mock<(...args: unknown[]) => Promise>; +type TestOutputMode = "json" | "table"; +type ClientFactoryMock = Mock< + (flags?: Record) => FakeContextClient +>; + +export interface FakeContextClient { + markets: { + list: AsyncMethodMock; + search: AsyncMethodMock; + get: AsyncMethodMock; + quotes: AsyncMethodMock; + fullOrderbook: AsyncMethodMock; + simulate: AsyncMethodMock; + priceHistory: AsyncMethodMock; + oracle: AsyncMethodMock; + oracleQuotes: AsyncMethodMock; + requestOracleQuote: AsyncMethodMock; + activity: AsyncMethodMock; + globalActivity: AsyncMethodMock; + create: AsyncMethodMock; + }; + orders: { + list: AsyncMethodMock; + mine: AsyncMethodMock; + get: AsyncMethodMock; + recent: AsyncMethodMock; + simulate: AsyncMethodMock; + create: AsyncMethodMock; + createMarket: AsyncMethodMock; + cancel: AsyncMethodMock; + cancelReplace: AsyncMethodMock; + bulkCreate: AsyncMethodMock; + bulkCancel: AsyncMethodMock; + bulk: AsyncMethodMock; + }; + portfolio: { + balance: AsyncMethodMock; + stats: AsyncMethodMock; + get: AsyncMethodMock; + claimable: AsyncMethodMock; + tokenBalance: AsyncMethodMock; + }; + account: { + status: AsyncMethodMock; + setup: AsyncMethodMock; + mintTestUsdc: AsyncMethodMock; + deposit: AsyncMethodMock; + withdraw: AsyncMethodMock; + mintCompleteSets: AsyncMethodMock; + burnCompleteSets: AsyncMethodMock; + gaslessSetup: AsyncMethodMock; + gaslessDeposit: AsyncMethodMock; + }; + questions: { + submit: AsyncMethodMock; + status: AsyncMethodMock; + submitAndWait: AsyncMethodMock; + agentSubmit: AsyncMethodMock; + agentSubmitAndWait: AsyncMethodMock; + }; +} + +interface CommandMockState { + outputMode: TestOutputMode; + client: FakeContextClient; + outCalls: OutCall[]; + failCalls: FailCall[]; + orderPromptCalls: OrderPromptCall[]; + actionPromptCalls: ActionPromptCall[]; + readClient: ClientFactoryMock; + tradingClient: ClientFactoryMock; + confirmOrder: Mock< + ( + summary: OrderPromptSummary, + flags: Record, + ) => Promise + >; + confirmAction: Mock< + (message: string, flags: Record) => Promise + >; +} + +function createAsyncMethodMock(): AsyncMethodMock { + return mock(async (..._args: unknown[]) => undefined); +} + +function createClient(): FakeContextClient { + return { + markets: { + list: createAsyncMethodMock(), + search: createAsyncMethodMock(), + get: createAsyncMethodMock(), + quotes: createAsyncMethodMock(), + fullOrderbook: createAsyncMethodMock(), + simulate: createAsyncMethodMock(), + priceHistory: createAsyncMethodMock(), + oracle: createAsyncMethodMock(), + oracleQuotes: createAsyncMethodMock(), + requestOracleQuote: createAsyncMethodMock(), + activity: createAsyncMethodMock(), + globalActivity: createAsyncMethodMock(), + create: createAsyncMethodMock(), + }, + orders: { + list: createAsyncMethodMock(), + mine: createAsyncMethodMock(), + get: createAsyncMethodMock(), + recent: createAsyncMethodMock(), + simulate: createAsyncMethodMock(), + create: createAsyncMethodMock(), + createMarket: createAsyncMethodMock(), + cancel: createAsyncMethodMock(), + cancelReplace: createAsyncMethodMock(), + bulkCreate: createAsyncMethodMock(), + bulkCancel: createAsyncMethodMock(), + bulk: createAsyncMethodMock(), + }, + portfolio: { + balance: createAsyncMethodMock(), + stats: createAsyncMethodMock(), + get: createAsyncMethodMock(), + claimable: createAsyncMethodMock(), + tokenBalance: createAsyncMethodMock(), + }, + account: { + status: createAsyncMethodMock(), + setup: createAsyncMethodMock(), + mintTestUsdc: createAsyncMethodMock(), + deposit: createAsyncMethodMock(), + withdraw: createAsyncMethodMock(), + mintCompleteSets: createAsyncMethodMock(), + burnCompleteSets: createAsyncMethodMock(), + gaslessSetup: createAsyncMethodMock(), + gaslessDeposit: createAsyncMethodMock(), + }, + questions: { + submit: createAsyncMethodMock(), + status: createAsyncMethodMock(), + submitAndWait: createAsyncMethodMock(), + agentSubmit: createAsyncMethodMock(), + agentSubmitAndWait: createAsyncMethodMock(), + }, + }; +} + +const state: CommandMockState = { + outputMode: "json", + client: createClient(), + outCalls: [], + failCalls: [], + orderPromptCalls: [], + actionPromptCalls: [], + readClient: mock((_flags?: Record) => state.client), + tradingClient: mock((_flags?: Record) => state.client), + confirmOrder: mock(async (summary, flags) => { + state.orderPromptCalls.push({ summary, flags }); + }), + confirmAction: mock(async (message, flags) => { + state.actionPromptCalls.push({ message, flags }); + }), +}; + +function fail(message: string, details?: unknown): never { + state.failCalls.push({ message, details }); + throw new FailError(message); +} + +function requireFlag( + flags: Record, + key: string, + usage: string, +): string { + const value = flags[key]; + if (!value || value === "true") { + fail(`Missing required flag: --${key}`, { usage }); + } + return value; +} + +function requirePositional( + positional: string[], + index: number, + name: string, + usage: string, +): string { + const value = positional[index]; + if (!value) { + fail(`Missing required argument: <${name}>`, { usage }); + } + return value; +} + +mock.module("../client.js", () => ({ + readClient: state.readClient, + tradingClient: state.tradingClient, +})); + +mock.module("../format.js", () => ({ + out(data: unknown, config?: unknown): void { + state.outCalls.push({ data, config }); + }, + fail, + getOutputMode(): TestOutputMode { + return state.outputMode; + }, + setOutputMode(flags: Record): void { + const explicit = flags.output ?? flags.o; + state.outputMode = explicit === "table" ? "table" : "json"; + }, + requireFlag, + requirePositional, +})); + +mock.module("../ui/prompt.js", () => ({ + confirmOrder: state.confirmOrder, + confirmAction: state.confirmAction, + CancelError, +})); + +function resetPromptMocks(): void { + state.confirmOrder.mockClear(); + state.confirmOrder.mockImplementation(async (summary, flags) => { + state.orderPromptCalls.push({ summary, flags }); + }); + + state.confirmAction.mockClear(); + state.confirmAction.mockImplementation(async (message, flags) => { + state.actionPromptCalls.push({ message, flags }); + }); +} + +export function resetCommandHarness(): void { + state.outputMode = "json"; + state.client = createClient(); + state.outCalls.length = 0; + state.failCalls.length = 0; + state.orderPromptCalls.length = 0; + state.actionPromptCalls.length = 0; + state.readClient.mockClear(); + state.tradingClient.mockClear(); + resetPromptMocks(); +} + +export const commandHarness = { + get client(): FakeContextClient { + return state.client; + }, + get outCalls(): OutCall[] { + return state.outCalls; + }, + get failCalls(): FailCall[] { + return state.failCalls; + }, + get orderPromptCalls(): OrderPromptCall[] { + return state.orderPromptCalls; + }, + get actionPromptCalls(): ActionPromptCall[] { + return state.actionPromptCalls; + }, + readClient: state.readClient, + tradingClient: state.tradingClient, + confirmOrder: state.confirmOrder, + confirmAction: state.confirmAction, + reset: resetCommandHarness, + setOutputMode(mode: TestOutputMode): void { + state.outputMode = mode; + }, + async callCommand( + handler: (parsed: ParsedArgs) => Promise, + args: Partial = {}, + ): Promise { + const parsed: ParsedArgs = { + command: args.command ?? "test", + subcommand: args.subcommand, + positional: args.positional ? [...args.positional] : [], + flags: args.flags ? { ...args.flags } : {}, + }; + await handler(parsed); + }, +}; diff --git a/src/__tests__/parseArgs.test.ts b/src/__tests__/parseArgs.test.ts new file mode 100644 index 0000000..f8d655a --- /dev/null +++ b/src/__tests__/parseArgs.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { parseArgs } from "../format.js"; + +describe("parseArgs", () => { + test("defaults to help when no command is provided", () => { + expect(parseArgs(["bun", "context"])).toEqual({ + command: "help", + subcommand: undefined, + positional: [], + flags: {}, + }); + }); + + test("parses --key value flags", () => { + expect( + parseArgs([ + "bun", + "context", + "markets", + "list", + "--status", + "active", + "--limit", + "5", + ]), + ).toEqual({ + command: "markets", + subcommand: "list", + positional: [], + flags: { + status: "active", + limit: "5", + }, + }); + }); + + test("parses --key=value flags", () => { + expect( + parseArgs([ + "bun", + "context", + "markets", + "search", + "election", + "--limit=10", + "--offset=20", + ]), + ).toEqual({ + command: "markets", + subcommand: "search", + positional: ["election"], + flags: { + limit: "10", + offset: "20", + }, + }); + }); + + test("maps -o to the output flag", () => { + expect( + parseArgs(["bun", "context", "orders", "mine", "-o", "json"]), + ).toEqual({ + command: "orders", + subcommand: "mine", + positional: [], + flags: { + output: "json", + }, + }); + }); + + test("treats bare flags as booleans", () => { + expect( + parseArgs(["bun", "context", "setup", "--yes", "--help"]), + ).toEqual({ + command: "setup", + subcommand: undefined, + positional: [], + flags: { + yes: "true", + help: "true", + }, + }); + }); + + test("keeps remaining positionals after the subcommand", () => { + expect( + parseArgs(["bun", "context", "portfolio", "token-balance", "0xabc", "0xdef"]), + ).toEqual({ + command: "portfolio", + subcommand: "token-balance", + positional: ["0xabc", "0xdef"], + flags: {}, + }); + }); + + test("parses mixed flags and positionals", () => { + expect( + parseArgs([ + "bun", + "context", + "orders", + "create", + "extra", + "--market", + "market-1", + "--price=42", + "--size", + "10", + "--yes", + ]), + ).toEqual({ + command: "orders", + subcommand: "create", + positional: ["extra"], + flags: { + market: "market-1", + price: "42", + size: "10", + yes: "true", + }, + }); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 8330c48..f0451e7 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ // --------------------------------------------------------------------------- import { parseArgs, fail, setOutputMode, getOutputMode } from "./format.js"; +import { cleanErrorMessage } from "./error-format.js"; import { printFail } from "./ui/output.js"; import chalk from "chalk"; @@ -44,76 +45,6 @@ Config: Run "context help" or "context --help" for command details.`; -// --------------------------------------------------------------------------- -// Error message cleanup — sanitize raw SDK / zod / viem errors for display -// --------------------------------------------------------------------------- - -/** Known EVM revert reason hex → human-readable message */ -const REVERT_REASONS: Record = { - "5452414e534645525f46524f4d5f4641494c4544": "USDC transfer failed — insufficient wallet balance", - "494e53554646494349454e545f42414c414e4345": "Insufficient balance", - "4e4f545f415554484f52495a4544": "Not authorized", -}; - -function decodeRevertHex(hex: string): string | null { - // Standard Solidity revert: 0x08c379a0 + offset + length + reason - const match = hex.match(/08c379a0[0-9a-f]{128}([0-9a-f]+)/i); - if (!match) return null; - const reasonHex = match[1].replace(/0+$/, ""); - for (const [known, label] of Object.entries(REVERT_REASONS)) { - if (reasonHex.toLowerCase().includes(known.toLowerCase())) return label; - } - // Try raw ASCII decode - try { - const bytes = Buffer.from(reasonHex, "hex"); - const text = bytes.toString("utf8").replace(/[^\x20-\x7E]/g, ""); - if (text.length > 2) return text; - } catch { /* ignore */ } - return null; -} - -function cleanErrorMessage(raw: string): string { - // Zod validation errors: "✖ Too big: expected number to be <=50\n → at limit" - const zodMatch = raw.match(/✖\s*(.+?)(?:\n\s*→\s*at\s+(\w+))?$/s); - if (zodMatch) { - const detail = zodMatch[1].trim(); - const field = zodMatch[2]; - return field ? `Invalid --${field}: ${detail}` : detail; - } - - // viem "insufficient funds" errors — extract the actionable part - if (raw.includes("exceeds the balance of the account")) { - return "Insufficient ETH for gas. Fund your wallet with testnet ETH on Base Sepolia."; - } - - // EVM revert with hex reason - const revertMatch = raw.match(/reverted.*?reason:\s*(0x[0-9a-f]+)/i); - if (revertMatch) { - const decoded = decodeRevertHex(revertMatch[1]); - if (decoded) return decoded; - } - - // UserOperation revert (gasless) - if (raw.includes("UserOperation reverted")) { - const hexMatch = raw.match(/(0x[0-9a-f]{64,})/i); - if (hexMatch) { - const decoded = decodeRevertHex(hexMatch[1]); - if (decoded) return decoded; - } - return "Transaction reverted — check your wallet balance and approvals."; - } - - // viem verbose errors: strip docs/version/request details - if (raw.includes("Docs: https://viem.sh") || raw.includes("Version: viem@")) { - const detailsMatch = raw.match(/Details:\s*(.+?)(?:\n|$)/); - if (detailsMatch) return detailsMatch[1].trim(); - // Fall back to first line - return raw.split("\n")[0].trim(); - } - - return raw; -} - async function main() { const parsed = parseArgs(process.argv); setOutputMode(parsed.flags); diff --git a/src/error-format.ts b/src/error-format.ts new file mode 100644 index 0000000..081cdeb --- /dev/null +++ b/src/error-format.ts @@ -0,0 +1,69 @@ +// --------------------------------------------------------------------------- +// Error message cleanup — sanitize raw SDK / zod / viem errors for display +// --------------------------------------------------------------------------- + +/** Known EVM revert reason hex → human-readable message */ +const REVERT_REASONS: Record = { + "5452414e534645525f46524f4d5f4641494c4544": + "USDC transfer failed — insufficient wallet balance", + "494e53554646494349454e545f42414c414e4345": "Insufficient balance", + "4e4f545f415554484f52495a4544": "Not authorized", +}; + +function decodeRevertHex(hex: string): string | null { + // Standard Solidity revert: 0x08c379a0 + offset + length + reason + const match = hex.match(/08c379a0[0-9a-f]{128}([0-9a-f]+)/i); + if (!match) return null; + + const reasonHex = match[1].replace(/0+$/, ""); + for (const [known, label] of Object.entries(REVERT_REASONS)) { + if (reasonHex.toLowerCase().includes(known.toLowerCase())) return label; + } + + try { + const bytes = Buffer.from(reasonHex, "hex"); + const text = bytes.toString("utf8").replace(/[^\x20-\x7E]/g, ""); + if (text.length > 2) return text; + } catch { + // Ignore invalid hex payloads and fall back to the raw error. + } + + return null; +} + +export function cleanErrorMessage(raw: string): string { + // Zod validation errors: "✖ Too big: expected number to be <=50\n → at limit" + const zodMatch = raw.match(/✖\s*(.+?)(?:\n\s*→\s*at\s+(\w+))?$/s); + if (zodMatch) { + const detail = zodMatch[1].trim(); + const field = zodMatch[2]; + return field ? `Invalid --${field}: ${detail}` : detail; + } + + if (raw.includes("exceeds the balance of the account")) { + return "Insufficient ETH for gas. Fund your wallet with testnet ETH on Base Sepolia."; + } + + const revertMatch = raw.match(/reverted.*?reason:\s*(0x[0-9a-f]+)/i); + if (revertMatch) { + const decoded = decodeRevertHex(revertMatch[1]); + if (decoded) return decoded; + } + + if (raw.includes("UserOperation reverted")) { + const hexMatch = raw.match(/(0x[0-9a-f]{64,})/i); + if (hexMatch) { + const decoded = decodeRevertHex(hexMatch[1]); + if (decoded) return decoded; + } + return "Transaction reverted — check your wallet balance and approvals."; + } + + if (raw.includes("Docs: https://viem.sh") || raw.includes("Version: viem@")) { + const detailsMatch = raw.match(/Details:\s*(.+?)(?:\n|$)/); + if (detailsMatch) return detailsMatch[1].trim(); + return raw.split("\n")[0].trim(); + } + + return raw; +}