diff --git a/packages/thirdweb/src/auth/verify-hash.test.ts b/packages/thirdweb/src/auth/verify-hash.test.ts index 694a50acbf4..5d7259ce880 100644 --- a/packages/thirdweb/src/auth/verify-hash.test.ts +++ b/packages/thirdweb/src/auth/verify-hash.test.ts @@ -16,7 +16,7 @@ describe("verifyHash", async () => { privateKey: ANVIL_PKEY_A, }); - expect( + await expect( verifyHash({ address: TEST_ACCOUNT_A.address, hash: hashMessage("hello world"), @@ -33,7 +33,7 @@ describe("verifyHash", async () => { privateKey: ANVIL_PKEY_A, }); - expect( + await expect( verifyHash({ address: TEST_ACCOUNT_A.address, hash: hashMessage("hello world"), @@ -50,7 +50,7 @@ describe("verifyHash", async () => { privateKey: ANVIL_PKEY_A, }); - expect( + await expect( verifyHash({ address: TEST_ACCOUNT_A.address, hash: hashMessage("hello world"), diff --git a/packages/thirdweb/src/chains/utils.test.ts b/packages/thirdweb/src/chains/utils.test.ts index 7d63d148ade..d505620c9d0 100644 --- a/packages/thirdweb/src/chains/utils.test.ts +++ b/packages/thirdweb/src/chains/utils.test.ts @@ -9,12 +9,15 @@ import { toSerializableTransaction } from "../transaction/actions/to-serializabl import { privateKeyToAccount } from "../wallets/private-key.js"; import { avalanche } from "./chain-definitions/avalanche.js"; import { ethereum } from "./chain-definitions/ethereum.js"; -import type { LegacyChain } from "./types.js"; +import type { ChainMetadata, LegacyChain } from "./types.js"; +import { base } from "viem/chains"; import { CUSTOM_CHAIN_MAP, cacheChains, + convertApiChainToChain, convertLegacyChain, + convertViemChain, defineChain, getCachedChain, getChainDecimals, @@ -237,4 +240,70 @@ describe("defineChain", () => { cacheChains([scroll]); expect(CUSTOM_CHAIN_MAP.get(scroll.id)).toStrictEqual(scroll); }); + + it("Chain converted from viem should have the blockExplorers being an array", () => { + expect(Array.isArray(convertViemChain(base).blockExplorers)).toBe(true); + }); + + it("convertApiChainToChain should work", () => { + const ethChain: ChainMetadata = { + chainId: 1, + name: "Ethereum Mainnet", + chain: "ETH", + shortName: "eth", + icon: { + url: "ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/ethereum/512.png", + width: 512, + height: 512, + format: "png", + }, + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + ens: { + registry: "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + }, + explorers: [ + { + name: "etherscan", + url: "https://etherscan.io", + standard: "EIP3091", + }, + ], + rpc: ["https://1.rpc.thirdweb.com/${THIRDWEB_API_KEY}"], + testnet: false, + infoURL: "https://ethereum.org", + slug: "ethereum", + networkId: 1, + stackType: "l1", + }; + + expect(convertApiChainToChain(ethChain)).toStrictEqual({ + blockExplorers: [ + { + apiUrl: "https://etherscan.io", + name: "etherscan", + url: "https://etherscan.io", + }, + ], + faucets: undefined, + icon: { + format: "png", + height: 512, + url: "ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/ethereum/512.png", + width: 512, + }, + id: 1, + name: "Ethereum Mainnet", + nativeCurrency: { + decimals: 18, + name: "Ether", + symbol: "ETH", + }, + rpc: "https://1.rpc.thirdweb.com/${THIRDWEB_API_KEY}", + testnet: undefined, + }); + }); }); diff --git a/packages/thirdweb/src/chains/utils.ts b/packages/thirdweb/src/chains/utils.ts index 2615f7b0c9e..09db74574ea 100644 --- a/packages/thirdweb/src/chains/utils.ts +++ b/packages/thirdweb/src/chains/utils.ts @@ -132,7 +132,10 @@ function isViemChain( return "rpcUrls" in chain && !("rpc" in chain); } -function convertViemChain(viemChain: ViemChain): Chain { +/** + * @internal + */ +export function convertViemChain(viemChain: ViemChain): Chain { const RPC_URL = getThirdwebDomains().rpc; return { id: viemChain.id, diff --git a/packages/thirdweb/src/contract/contract.test.ts b/packages/thirdweb/src/contract/contract.test.ts new file mode 100644 index 00000000000..4c8c128db3f --- /dev/null +++ b/packages/thirdweb/src/contract/contract.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { USDT_CONTRACT_ADDRESS } from "~test/test-contracts.js"; +import { ethereum } from "../chains/chain-definitions/ethereum.js"; +import { getContract } from "./contract.js"; + +describe("Contract - getContract", () => { + it("should throw error if client is not passed", () => { + expect(() => + // @ts-ignore Test purpose + getContract({ address: "0x", chain: ethereum }), + ).toThrowError( + `getContract validation error - invalid client: ${undefined}`, + ); + }); + + it("should throw error if address is not valid", () => { + expect(() => + getContract({ address: "0x", chain: ethereum, client: TEST_CLIENT }), + ).toThrowError("getContract validation error - invalid address: 0x"); + }); + + it("should throw error if chain is not passed", () => { + expect(() => + // @ts-ignore Test purpose + getContract({ address: USDT_CONTRACT_ADDRESS, client: TEST_CLIENT }), + ).toThrowError( + `getContract validation error - invalid chain: ${undefined}`, + ); + }); + + it("should throw error if chain doesn't have id", () => { + expect(() => + getContract({ + address: USDT_CONTRACT_ADDRESS, + client: TEST_CLIENT, + // @ts-ignore Test + chain: {}, + }), + ).toThrowError(`getContract validation error - invalid chain: ${{}}`); + }); +}); diff --git a/packages/thirdweb/src/exports/utils.ts b/packages/thirdweb/src/exports/utils.ts index 3ff52e20378..8a5491d8dec 100644 --- a/packages/thirdweb/src/exports/utils.ts +++ b/packages/thirdweb/src/exports/utils.ts @@ -1,7 +1,7 @@ // bytecode export { detectMethod } from "../utils/bytecode/detectExtension.js"; export { extractIPFSUri } from "../utils/bytecode/extractIPFS.js"; -export { extractMinimalProxyImplementationAddress } from "../utils/bytecode/extractMnimalProxyImplementationAddress.js"; +export { extractMinimalProxyImplementationAddress } from "../utils/bytecode/extractMinimalProxyImplementationAddress.js"; export { isContractDeployed } from "../utils/bytecode/is-contract-deployed.js"; export { ensureBytecodePrefix } from "../utils/bytecode/prefix.js"; export { resolveImplementation } from "../utils/bytecode/resolveImplementation.js"; diff --git a/packages/thirdweb/src/extensions/thirdweb/write/publish.test.ts b/packages/thirdweb/src/extensions/thirdweb/write/publish.test.ts index 540b306b873..b93618f9180 100644 --- a/packages/thirdweb/src/extensions/thirdweb/write/publish.test.ts +++ b/packages/thirdweb/src/extensions/thirdweb/write/publish.test.ts @@ -105,7 +105,7 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential("publishContract", () => { expect(publishedContracts.length).toBe(1); - expect( + await expect( sendAndConfirmTransaction({ account: TEST_ACCOUNT_D, transaction: publishContract({ diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/currencies.test.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/currencies.test.tsx new file mode 100644 index 00000000000..eadc9fbd0f9 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/currencies.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { CADIcon } from "../../../icons/currencies/CADIcon.js"; +import { EURIcon } from "../../../icons/currencies/EURIcon.js"; +import { GBPIcon } from "../../../icons/currencies/GBPIcon.js"; +import { JPYIcon } from "../../../icons/currencies/JPYIcon.js"; +import { USDIcon } from "../../../icons/currencies/USDIcon.js"; +import { currencies, getCurrencyMeta, usdCurrency } from "./currencies.js"; + +describe("Currency Utilities", () => { + it("should have correct number of currencies", () => { + expect(currencies.length).toBe(5); + }); + + it("should have USD as the first currency", () => { + expect(currencies[0]).toEqual(usdCurrency); + }); + + it("should have correct properties for each currency", () => { + for (const currency of currencies) { + expect(currency).toHaveProperty("shorthand"); + expect(currency).toHaveProperty("name"); + expect(currency).toHaveProperty("icon"); + } + }); + + describe("getCurrencyMeta function", () => { + it("should return correct currency meta for valid shorthand", () => { + const cadMeta = getCurrencyMeta("CAD"); + expect(cadMeta.shorthand).toBe("CAD"); + expect(cadMeta.name).toBe("Canadian Dollar"); + expect(cadMeta.icon).toBe(CADIcon); + }); + + it("should be case-insensitive", () => { + const eurMeta = getCurrencyMeta("eur"); + expect(eurMeta.shorthand).toBe("EUR"); + expect(eurMeta.name).toBe("Euro"); + expect(eurMeta.icon).toBe(EURIcon); + }); + + it("should return unknown currency for invalid shorthand", () => { + const unknownMeta = getCurrencyMeta("XYZ"); + expect(unknownMeta.shorthand).toBe("XYZ"); + expect(unknownMeta.name).toBe("XYZ"); + expect(unknownMeta.icon).not.toBe(USDIcon); + expect(unknownMeta.icon).not.toBe(CADIcon); + expect(unknownMeta.icon).not.toBe(GBPIcon); + expect(unknownMeta.icon).not.toBe(EURIcon); + expect(unknownMeta.icon).not.toBe(JPYIcon); + }); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/pay-transactions/statusMeta.test.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/pay-transactions/statusMeta.test.ts new file mode 100644 index 00000000000..bf681b6e55e --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/pay-transactions/statusMeta.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import type { BuyWithCryptoStatus } from "../../../../../../../pay/buyWithCrypto/getStatus.js"; +import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js"; +import { + getBuyWithCryptoStatusMeta, + getBuyWithFiatStatusMeta, +} from "./statusMeta.js"; + +describe("getBuyWithCryptoStatusMeta", () => { + it('returns "Unknown" for NOT_FOUND status', () => { + const result = getBuyWithCryptoStatusMeta({ + status: "NOT_FOUND", + } as BuyWithCryptoStatus); + expect(result).toEqual({ + status: "Unknown", + color: "secondaryText", + }); + }); + + it('returns "Bridging" for WAITING_BRIDGE subStatus', () => { + const result = getBuyWithCryptoStatusMeta({ + status: "PENDING", + subStatus: "WAITING_BRIDGE", + } as BuyWithCryptoStatus); + expect(result).toEqual({ + status: "Bridging", + color: "accentText", + loading: true, + }); + }); + + it('returns "Incomplete" for PARTIAL_SUCCESS subStatus', () => { + const result = getBuyWithCryptoStatusMeta({ + status: "COMPLETED", + subStatus: "PARTIAL_SUCCESS", + } as BuyWithCryptoStatus); + expect(result).toEqual({ + status: "Incomplete", + color: "secondaryText", + }); + }); + + it('returns "Pending" for PENDING status', () => { + const result = getBuyWithCryptoStatusMeta({ + status: "PENDING", + } as BuyWithCryptoStatus); + expect(result).toEqual({ + status: "Pending", + color: "accentText", + loading: true, + }); + }); + + it('returns "Failed" for FAILED status', () => { + const result = getBuyWithCryptoStatusMeta({ + status: "FAILED", + } as BuyWithCryptoStatus); + expect(result).toEqual({ + status: "Failed", + color: "danger", + }); + }); + + it('returns "Completed" for COMPLETED status', () => { + const result = getBuyWithCryptoStatusMeta({ + status: "COMPLETED", + } as BuyWithCryptoStatus); + expect(result).toEqual({ + status: "Completed", + color: "success", + }); + }); + + it('returns "Unknown" for unhandled status', () => { + const result = getBuyWithCryptoStatusMeta({ + // @ts-ignore Test purpose + status: "Unknown", + }); + expect(result).toEqual({ + status: "Unknown", + color: "secondaryText", + }); + }); +}); + +describe("getBuyWithFiatStatusMeta", () => { + it('returns "Incomplete" for CRYPTO_SWAP_FALLBACK status', () => { + const result = getBuyWithFiatStatusMeta({ + status: "CRYPTO_SWAP_FALLBACK", + } as BuyWithFiatStatus); + expect(result).toEqual({ + status: "Incomplete", + color: "danger", + step: 2, + progressStatus: "partialSuccess", + }); + }); + + it('returns "Pending" for CRYPTO_SWAP_IN_PROGRESS status', () => { + const result = getBuyWithFiatStatusMeta({ + status: "CRYPTO_SWAP_IN_PROGRESS", + } as BuyWithFiatStatus); + expect(result).toEqual({ + status: "Pending", + color: "accentText", + loading: true, + step: 2, + progressStatus: "pending", + }); + }); + + it('returns "Pending" for PENDING_ON_RAMP_TRANSFER status', () => { + const result = getBuyWithFiatStatusMeta({ + status: "PENDING_ON_RAMP_TRANSFER", + } as BuyWithFiatStatus); + expect(result).toEqual({ + status: "Pending", + color: "accentText", + loading: true, + step: 1, + progressStatus: "pending", + }); + }); + + it('returns "Completed" for ON_RAMP_TRANSFER_COMPLETED status', () => { + const result = getBuyWithFiatStatusMeta({ + status: "ON_RAMP_TRANSFER_COMPLETED", + } as BuyWithFiatStatus); + expect(result).toEqual({ + status: "Completed", + color: "success", + loading: true, + step: 1, + progressStatus: "completed", + }); + }); + + it('returns "Action Required" for CRYPTO_SWAP_REQUIRED status', () => { + const result = getBuyWithFiatStatusMeta({ + status: "CRYPTO_SWAP_REQUIRED", + } as BuyWithFiatStatus); + expect(result).toEqual({ + status: "Action Required", + color: "accentText", + step: 2, + progressStatus: "actionRequired", + }); + }); + + it('returns "Failed" for PAYMENT_FAILED status', () => { + const result = getBuyWithFiatStatusMeta({ + status: "PAYMENT_FAILED", + } as BuyWithFiatStatus); + expect(result).toEqual({ + status: "Failed", + color: "danger", + step: 1, + progressStatus: "failed", + }); + }); + + it('returns "Unknown" for unhandled status', () => { + const result = getBuyWithFiatStatusMeta({ + // @ts-ignore + status: "UNKNOWN_STATUS", + }); + expect(result).toEqual({ + status: "Unknown", + color: "secondaryText", + step: 1, + progressStatus: "unknown", + }); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/formatSeconds.test.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/formatSeconds.test.ts new file mode 100644 index 00000000000..cdb02e541d5 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/formatSeconds.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { formatSeconds } from "./formatSeconds.js"; + +describe("formatSeconds", () => { + it("formats seconds to hours and minutes when over 3600 seconds", () => { + expect(formatSeconds(3601)).toBe("1 Hours 0 Minutes"); + expect(formatSeconds(7200)).toBe("2 Hours 0 Minutes"); + expect(formatSeconds(5400)).toBe("1 Hours 30 Minutes"); + expect(formatSeconds(12345)).toBe("3 Hours 25 Minutes"); + }); + + it("formats seconds to minutes when between 61 and 3600 seconds", () => { + expect(formatSeconds(61)).toBe("2 Minutes"); + expect(formatSeconds(120)).toBe("2 Minutes"); + expect(formatSeconds(3599)).toBe("60 Minutes"); + expect(formatSeconds(1800)).toBe("30 Minutes"); + }); + + it('formats seconds to "Xs" when 60 seconds or less', () => { + expect(formatSeconds(60)).toBe("60s"); + expect(formatSeconds(59)).toBe("59s"); + expect(formatSeconds(1)).toBe("1s"); + expect(formatSeconds(0)).toBe("0s"); + }); + + it("handles decimal inputs by rounding down for hours/minutes and up for minutes only", () => { + expect(formatSeconds(3661.5)).toBe("1 Hours 1 Minutes"); + expect(formatSeconds(119.9)).toBe("2 Minutes"); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/utils.test.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/utils.test.ts new file mode 100644 index 00000000000..bebb6d0ab6e --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/utils.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { getBuyTokenAmountFontSize } from "./utils.js"; + +describe("getBuyTokenAmountFontSize", () => { + it("returns 26px for strings longer than 10 characters", () => { + expect(getBuyTokenAmountFontSize("12345678901")).toBe("26px"); + expect(getBuyTokenAmountFontSize("1234567890123")).toBe("26px"); + }); + + it("returns 34px for strings longer than 6 characters but not more than 10", () => { + expect(getBuyTokenAmountFontSize("1234567")).toBe("34px"); + expect(getBuyTokenAmountFontSize("1234567890")).toBe("34px"); + }); + + it("returns 50px for strings 6 characters or shorter", () => { + expect(getBuyTokenAmountFontSize("123456")).toBe("50px"); + expect(getBuyTokenAmountFontSize("12345")).toBe("50px"); + expect(getBuyTokenAmountFontSize("")).toBe("50px"); + }); +}); diff --git a/packages/thirdweb/src/react/web/wallets/injected/locale/getInjectedWalletLocale.test.ts b/packages/thirdweb/src/react/web/wallets/injected/locale/getInjectedWalletLocale.test.ts new file mode 100644 index 00000000000..2eec444291b --- /dev/null +++ b/packages/thirdweb/src/react/web/wallets/injected/locale/getInjectedWalletLocale.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { LocaleId } from "../../../../../react/web/ui/types.js"; +import br from "./br.js"; +import de from "./de.js"; +import en from "./en.js"; +import es from "./es.js"; +import fr from "./fr.js"; +import { getInjectedWalletLocale } from "./getInjectedWalletLocale.js"; +import ja from "./ja.js"; +import kr from "./kr.js"; +import tl from "./tl.js"; +import vi from "./vi.js"; + +const locales: { locale: LocaleId; content: object }[] = [ + { locale: "es_ES", content: es }, + { locale: "ja_JP", content: ja }, + { locale: "tl_PH", content: tl }, + { locale: "vi_VN", content: vi }, + { locale: "de_DE", content: de }, + { locale: "ko_KR", content: kr }, + { locale: "fr_FR", content: fr }, + { locale: "pt_BR", content: br }, +]; + +describe("getInjectedWalletLocale", () => { + for (const item of locales) { + it(`should return the correct locale structure for ${item.locale}`, async () => { + expect(await getInjectedWalletLocale(item.locale)).toStrictEqual( + item.content, + ); + }); + } + + it("should return the default locale being ENGLISH for an unsupported locale", async () => { + expect( + await getInjectedWalletLocale("unsupported_locale" as LocaleId), + ).toBe(en); + }); +}); diff --git a/packages/thirdweb/src/react/web/wallets/smartWallet/locale/getSmartWalletLocale.test.ts b/packages/thirdweb/src/react/web/wallets/smartWallet/locale/getSmartWalletLocale.test.ts new file mode 100644 index 00000000000..c9602b85343 --- /dev/null +++ b/packages/thirdweb/src/react/web/wallets/smartWallet/locale/getSmartWalletLocale.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { LocaleId } from "../../../../../react/web/ui/types.js"; +import br from "./br.js"; +import de from "./de.js"; +import en from "./en.js"; +import es from "./es.js"; +import fr from "./fr.js"; +import { getSmartWalletLocale } from "./getSmartWalletLocale.js"; +import ja from "./ja.js"; +import kr from "./kr.js"; +import tl from "./tl.js"; +import vi from "./vi.js"; + +const locales: { locale: LocaleId; content: object }[] = [ + { locale: "es_ES", content: es }, + { locale: "ja_JP", content: ja }, + { locale: "tl_PH", content: tl }, + { locale: "vi_VN", content: vi }, + { locale: "de_DE", content: de }, + { locale: "ko_KR", content: kr }, + { locale: "fr_FR", content: fr }, + { locale: "pt_BR", content: br }, +]; + +describe("getInjectedWalletLocale", () => { + for (const item of locales) { + it(`should return the correct locale structure for ${item.locale}`, async () => { + expect(await getSmartWalletLocale(item.locale)).toStrictEqual( + item.content, + ); + }); + } + + it("should return the default locale being ENGLISH for an unsupported locale", async () => { + expect(await getSmartWalletLocale("unsupported_locale" as LocaleId)).toBe( + en, + ); + }); +}); diff --git a/packages/thirdweb/src/utils/abi/normalizeFunctionParams.test.ts b/packages/thirdweb/src/utils/abi/normalizeFunctionParams.test.ts new file mode 100644 index 00000000000..35d377f10c8 --- /dev/null +++ b/packages/thirdweb/src/utils/abi/normalizeFunctionParams.test.ts @@ -0,0 +1,108 @@ +import type { AbiFunction } from "abitype"; +import { describe, expect, it, vi } from "vitest"; +import { parseAbiParams } from "../contract/parse-abi-params.js"; +import { normalizeFunctionParams } from "./normalizeFunctionParams.js"; + +vi.mock("../contract/parse-abi-params.js", () => { + return { + parseAbiParams: vi.fn((_types, values) => values), + }; +}); + +describe("normalizeFunctionParams", () => { + it("should return an empty array when abiFunction is undefined", () => { + const result = normalizeFunctionParams(undefined, {}); + expect(result).toEqual([]); + }); + + it("should normalize and return function parameters correctly", () => { + const abiFunction: AbiFunction = { + inputs: [ + { name: "_param1", type: "uint256" }, + { name: "_param2", type: "string" }, + ], + type: "function", + stateMutability: "pure", + name: "test", + outputs: [], + }; + const params = { + param1: 123, + param2: "hello", + }; + + const result = normalizeFunctionParams(abiFunction, params); + expect(result).toEqual([123, "hello"]); + }); + + it("should handle parameter names with underscores", () => { + const abiFunction: AbiFunction = { + inputs: [ + { name: "_param1", type: "uint256" }, + { name: "_param2", type: "string" }, + ], + type: "function", + stateMutability: "pure", + name: "test", + outputs: [], + }; + const params = { + _param1: 123, + param2: "hello", + }; + + const result = normalizeFunctionParams(abiFunction, params); + expect(result).toEqual([123, "hello"]); + }); + + it("should throw an error if a parameter name is missing", () => { + const abiFunction: AbiFunction = { + inputs: [{ name: undefined, type: "uint256" }], + type: "function", + stateMutability: "pure", + name: "test", + outputs: [], + }; + + expect(() => normalizeFunctionParams(abiFunction, {})).toThrow( + "Missing named parameter for test at index 0", + ); + }); + + it("should throw an error if a parameter value is missing", () => { + const abiFunction: AbiFunction = { + inputs: [{ name: "_param1", type: "uint256" }], + name: "testFunction", + type: "function", + stateMutability: "pure", + outputs: [], + }; + + expect(() => normalizeFunctionParams(abiFunction, {})).toThrow( + "Missing value for parameter _param1 at index 0", + ); + }); + + it("should call parseAbiParams with the correct arguments", () => { + const abiFunction: AbiFunction = { + inputs: [ + { name: "_param1", type: "uint256" }, + { name: "_param2", type: "string" }, + ], + type: "function", + stateMutability: "pure", + name: "test", + outputs: [], + }; + const params = { + param1: 123, + param2: "hello", + }; + + normalizeFunctionParams(abiFunction, params); + expect(parseAbiParams).toHaveBeenCalledWith( + ["uint256", "string"], + [123, "hello"], + ); + }); +}); diff --git a/packages/thirdweb/src/utils/bigint.test.ts b/packages/thirdweb/src/utils/bigint.test.ts index c80b657f2d3..bba637b52c4 100644 --- a/packages/thirdweb/src/utils/bigint.test.ts +++ b/packages/thirdweb/src/utils/bigint.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { max, min, toBigInt } from "./bigint.js"; +import { max, min, replaceBigInts, toBigInt } from "./bigint.js"; describe("min", () => { it("should return the smaller value when a is smaller than b", () => { @@ -79,4 +79,94 @@ describe("toBigInt", () => { `Expected value to be an integer to convert to a bigint, got ${value} of type number`, ); }); + + it("should convert a Uint8Array to a BigInt", () => { + const uint8Array = new Uint8Array([1, 2, 3, 4]); + const result = toBigInt(uint8Array); + expect(result).toBe(BigInt("0x01020304")); + }); + + it("should handle a Uint8Array with leading zeros", () => { + const uint8Array = new Uint8Array([0, 0, 1, 2]); + const result = toBigInt(uint8Array); + expect(result).toBe(BigInt("0x00000102")); + }); + + it("should handle a large Uint8Array", () => { + const uint8Array = new Uint8Array([255, 255, 255, 255]); + const result = toBigInt(uint8Array); + expect(result).toBe(BigInt("0xffffffff")); + }); + + describe("replaceBigInts", () => { + it("should replace a single bigint value", () => { + const input = BigInt(123); + const result = replaceBigInts(input, (x) => x.toString()); + expect(result).toBe("123"); + }); + + it("should handle arrays containing bigints", () => { + const input = [BigInt(1), BigInt(2), BigInt(3)]; + const result = replaceBigInts(input, (x) => x.toString()); + expect(result).toEqual(["1", "2", "3"]); + }); + + it("should handle nested arrays with bigints", () => { + const input = [BigInt(1), [BigInt(2), BigInt(3)]]; + const result = replaceBigInts(input, (x) => x.toString()); + expect(result).toEqual(["1", ["2", "3"]]); + }); + + it("should handle objects containing bigints", () => { + const input = { a: BigInt(1), b: BigInt(2) }; + const result = replaceBigInts(input, (x) => x.toString()); + expect(result).toEqual({ a: "1", b: "2" }); + }); + + it("should handle nested objects with bigints", () => { + const input = { a: BigInt(1), b: { c: BigInt(2), d: BigInt(3) } }; + const result = replaceBigInts(input, (x) => x.toString()); + expect(result).toEqual({ a: "1", b: { c: "2", d: "3" } }); + }); + + it("should handle mixed arrays and objects", () => { + const input = { + a: [BigInt(1), { b: BigInt(2), c: [BigInt(3)] }], + }; + const result = replaceBigInts(input, (x) => x.toString()); + expect(result).toEqual({ a: ["1", { b: "2", c: ["3"] }] }); + }); + + it("should handle empty arrays and objects", () => { + expect(replaceBigInts([], (x) => x.toString())).toEqual([]); + expect(replaceBigInts({}, (x) => x.toString())).toEqual({}); + }); + + it("should leave other types untouched", () => { + const input = { + a: "string", + b: 42, + c: null, + d: undefined, + e: true, + f: [1, "test"], + }; + const result = replaceBigInts(input, (x) => x.toString()); + expect(result).toEqual(input); + }); + + it("should handle complex deeply nested structures", () => { + const input = { + a: BigInt(1), + b: [BigInt(2), { c: [BigInt(3), { d: BigInt(4) }] }], + e: { f: { g: BigInt(5) } }, + }; + const result = replaceBigInts(input, (x) => x.toString()); + expect(result).toEqual({ + a: "1", + b: ["2", { c: ["3", { d: "4" }] }], + e: { f: { g: "5" } }, + }); + }); + }); }); diff --git a/packages/thirdweb/src/utils/bytecode/extractMinimalProxyImplementationAddress.test.ts b/packages/thirdweb/src/utils/bytecode/extractMinimalProxyImplementationAddress.test.ts new file mode 100644 index 00000000000..8efe9398594 --- /dev/null +++ b/packages/thirdweb/src/utils/bytecode/extractMinimalProxyImplementationAddress.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { extractMinimalProxyImplementationAddress } from "./extractMinimalProxyImplementationAddress.js"; + +describe("extractMinimalProxyImplementationAddress", () => { + it("should handle bytecode without 0x prefix", () => { + const bytecode = + "363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3"; + const result = extractMinimalProxyImplementationAddress(bytecode); + expect(result).toBe("0xbebebebebebebebebebebebebebebebebebebebe"); + }); + + it("should extract address from EIP-1167 clone minimal proxy", () => { + const bytecode = + "0x363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3"; + const result = extractMinimalProxyImplementationAddress(bytecode); + expect(result).toBe("0xbebebebebebebebebebebebebebebebebebebebe"); + }); + + it("should extract address from 0xSplits minimal proxy", () => { + const bytecode = + "0x36603057343d52307f830d2d700a97af574b186c80d40429385d24241565b08a7c559ba283a964d9b1583103d5942010000000000000000000000001234567890123456789012345678901234567890"; + const result = extractMinimalProxyImplementationAddress(bytecode); + expect(result).toBe("0x234567890123456789012345678901234567890"); + }); + + it("should extract address from 0age's minimal proxy", () => { + const bytecode = + "0x3d3d3d3d363d3d37363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3"; + const result = extractMinimalProxyImplementationAddress(bytecode); + expect(result).toBe("0xbebebebebebebebebebebebebebebebebebebebe"); + }); + + it("should extract address from Vyper's minimal proxy (Uniswap v1)", () => { + const bytecode = + "0x366000600037611000600036600073bebebebebebebebebebebebebebebebebebebebe5af41558576110006000f3"; + const result = extractMinimalProxyImplementationAddress(bytecode); + expect(result).toBe("0xbebebebebebebebebebebebebebebebebebebebe"); + }); + + it("should extract address from alternative Vyper minimal proxy", () => { + const bytecode = + "0x36600080376020600036600073bebebebebebebebebebebebebebebebebebebebe5af41558576020600060006000f3"; + const result = extractMinimalProxyImplementationAddress(bytecode); + expect(result).toBe("0xbebebebebebebebebebebebebebebebebebebebe"); + }); + + it("should extract address from EIP-7511 minimal proxy with PUSH0 opcode", () => { + const bytecode = + "0x365f5f375f5f365f73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3"; + const result = extractMinimalProxyImplementationAddress(bytecode); + expect(result).toBe("0xbebebebebebebebebebebebebebebebebebebebe"); + }); + + it("should return undefined for non-matching bytecode", () => { + const bytecode = + "0x60806040526000805534801561001457600080fd5b50610150806100246000396000f3fe"; + const result = extractMinimalProxyImplementationAddress(bytecode); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/thirdweb/src/utils/bytecode/extractMnimalProxyImplementationAddress.ts b/packages/thirdweb/src/utils/bytecode/extractMinimalProxyImplementationAddress.ts similarity index 100% rename from packages/thirdweb/src/utils/bytecode/extractMnimalProxyImplementationAddress.ts rename to packages/thirdweb/src/utils/bytecode/extractMinimalProxyImplementationAddress.ts diff --git a/packages/thirdweb/src/utils/bytecode/resolveImplementation.ts b/packages/thirdweb/src/utils/bytecode/resolveImplementation.ts index 7522baa7d36..38b9e82f5f6 100644 --- a/packages/thirdweb/src/utils/bytecode/resolveImplementation.ts +++ b/packages/thirdweb/src/utils/bytecode/resolveImplementation.ts @@ -5,7 +5,7 @@ import { getRpcClient } from "../../rpc/rpc.js"; import { readContract } from "../../transaction/read-contract.js"; import { isAddress } from "../address.js"; import type { Hex } from "../encoding/hex.js"; -import { extractMinimalProxyImplementationAddress } from "./extractMnimalProxyImplementationAddress.js"; +import { extractMinimalProxyImplementationAddress } from "./extractMinimalProxyImplementationAddress.js"; // TODO: move to const exports const AddressZero = "0x0000000000000000000000000000000000000000"; diff --git a/packages/thirdweb/src/utils/domain.test.ts b/packages/thirdweb/src/utils/domain.test.ts new file mode 100644 index 00000000000..4ab1c556aa6 --- /dev/null +++ b/packages/thirdweb/src/utils/domain.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + DEFAULT_RPC_URL, + getThirdwebBaseUrl, + getThirdwebDomains, + setThirdwebDomains, +} from "./domains.js"; + +describe("Thirdweb Domains", () => { + const defaultDomains = { + rpc: "rpc.thirdweb.com", + social: "social.thirdweb.com", + inAppWallet: "embedded-wallet.thirdweb.com", + pay: "pay.thirdweb.com", + storage: "storage.thirdweb.com", + bundler: "bundler.thirdweb.com", + analytics: "c.thirdweb.com", + }; + + beforeEach(() => { + // Reset to default domains before each test + setThirdwebDomains({}); + }); + + describe("getThirdwebDomains", () => { + it("should return the default domains if no overrides are set", () => { + expect(getThirdwebDomains()).toEqual(defaultDomains); + }); + }); + + describe("setThirdwebDomains", () => { + it("should override specific domains while keeping others as default", () => { + setThirdwebDomains({ + rpc: "custom.rpc.com", + analytics: "custom.analytics.com", + }); + + expect(getThirdwebDomains()).toEqual({ + ...defaultDomains, + rpc: "custom.rpc.com", + analytics: "custom.analytics.com", + }); + }); + + it("should not modify domains that are not overridden", () => { + setThirdwebDomains({ pay: "custom.pay.com" }); + + const domains = getThirdwebDomains(); + expect(domains.pay).toBe("custom.pay.com"); + expect(domains.rpc).toBe(defaultDomains.rpc); + expect(domains.analytics).toBe(defaultDomains.analytics); + }); + }); + + describe("getThirdwebBaseUrl", () => { + it("should return an HTTPS URL for non-localhost domains", () => { + const baseUrl = getThirdwebBaseUrl("rpc"); + expect(baseUrl).toBe(`https://${DEFAULT_RPC_URL}`); + }); + + it("should return an HTTP URL for localhost domains", () => { + setThirdwebDomains({ rpc: "localhost:8545" }); + const baseUrl = getThirdwebBaseUrl("rpc"); + expect(baseUrl).toBe("http://localhost:8545"); + }); + + it("should reflect the updated domain overrides", () => { + setThirdwebDomains({ storage: "custom.storage.com" }); + const baseUrl = getThirdwebBaseUrl("storage"); + expect(baseUrl).toBe("https://custom.storage.com"); + }); + + it("should throw an error if an invalid service is requested", () => { + // biome-ignore lint/suspicious/noExplicitAny: for test + expect(() => getThirdwebBaseUrl("invalid" as any)).toThrow(); + }); + }); +}); diff --git a/packages/thirdweb/src/utils/encoding/hex.test.ts b/packages/thirdweb/src/utils/encoding/hex.test.ts index c305c9d543c..cf8488d4de9 100644 --- a/packages/thirdweb/src/utils/encoding/hex.test.ts +++ b/packages/thirdweb/src/utils/encoding/hex.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { numberToHex } from "./hex.js"; +import { fromHex, numberToHex, padHex, toHex } from "./hex.js"; describe("hex.ts", () => { it("should convert number with no padding", () => { @@ -54,3 +54,165 @@ describe("hex.ts", () => { ); }); }); + +describe("toHex without parameters", () => { + it("should convert number to hex", () => { + expect(toHex(1)).toBe("0x1"); + }); + + it("should convert bigint to hex", () => { + expect(toHex(1n)).toBe("0x1"); + }); + + it("should convert string to hex", () => { + expect(toHex("1")).toBe("0x31"); + }); + + it("should convert boolean to hex", () => { + expect(toHex(true)).toBe("0x1"); + expect(toHex(false)).toBe("0x0"); + }); + + it("should convert uint8 array to hex", () => { + expect(toHex(new Uint8Array([42, 255, 0, 128, 64]))).toBe("0x2aff008040"); + }); +}); + +describe("toHex WITH parameters", () => { + it("should convert number to hex", () => { + expect(toHex(1, { size: 32 })).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000001", + ); + }); + + it("should convert bigint to hex", () => { + expect(toHex(1n, { size: 32 })).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000001", + ); + }); + + it("should convert string to hex", () => { + expect(toHex("1", { size: 32 })).toBe( + "0x3100000000000000000000000000000000000000000000000000000000000000", + ); + }); + + it("should convert boolean to hex", () => { + expect(toHex(true, { size: 32 })).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000001", + ); + expect(toHex(false, { size: 32 })).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000000", + ); + }); + + it("should convert uint8 array to hex", () => { + expect(toHex(new Uint8Array([42, 255, 0, 128, 64]), { size: 32 })).toBe( + "0x2aff008040000000000000000000000000000000000000000000000000000000", + ); + }); +}); + +describe("fromHex without parameter", () => { + it("should convert hex to number", () => { + expect(fromHex("0x1", { to: "number" })).toBe(1); + }); + + it("should convert hex to bigint", () => { + expect(fromHex("0x1", { to: "bigint" })).toBe(1n); + }); + + it("should convert hex to string", () => { + expect(fromHex("0x31", "string")).toBe("1"); + }); + + it("should convert hex to boolean:true", () => { + expect(fromHex("0x1", "boolean")).toBe(true); + }); + + it("should convert hex to boolean:false", () => { + expect(fromHex("0x0", "boolean")).toBe(false); + }); + + it("should convert hex to uint8 array", () => { + expect(fromHex("0x2aff008040", "bytes")).toStrictEqual( + new Uint8Array([42, 255, 0, 128, 64]), + ); + }); +}); + +describe("fromHex WITH parameter", () => { + it("should convert hex to number", () => { + expect( + fromHex( + "0x0000000000000000000000000000000000000000000000000000000000000001", + { to: "number", size: 32 }, + ), + ).toBe(1); + }); + + it("should convert hex to bigint", () => { + expect( + fromHex( + "0x0000000000000000000000000000000000000000000000000000000000000001", + { to: "bigint", size: 32 }, + ), + ).toBe(1n); + }); + + it("should convert hex to string", () => { + expect( + fromHex( + "0x3100000000000000000000000000000000000000000000000000000000000000", + { to: "string", size: 32 }, + ), + ).toBe("1"); + }); + + it("should convert hex to boolean:true", () => { + expect( + fromHex( + "0x0000000000000000000000000000000000000000000000000000000000000001", + { to: "boolean", size: 32 }, + ), + ).toBe(true); + }); + + it("should convert hex to boolean:false", () => { + expect( + fromHex( + "0x0000000000000000000000000000000000000000000000000000000000000000", + { to: "boolean", size: 32 }, + ), + ).toBe(false); + }); + + it("should convert hex to uint8 array", () => { + expect( + fromHex( + "0x2aff008040000000000000000000000000000000000000000000000000000000", + { to: "bytes", size: 32 }, + ).toString(), + ).toStrictEqual( + "42,255,0,128,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0", + ); + }); +}); + +describe("padHex", () => { + it("should return the original value if size is undefined", () => { + expect(padHex("0x0", { size: null })).toBe("0x0"); + }); + + it("should produce correct result if padding direction is right", () => { + expect(padHex("0x1", { size: 10, dir: "right" })).toBe( + "0x10000000000000000000", + ); + }); + + it("should produce correct result if padding direction is Left", () => { + expect(padHex("0x1", { size: 10, dir: "left" })).toBe( + "0x00000000000000000001", + ); + }); +}); diff --git a/packages/thirdweb/src/utils/ens/encodeLabelToLabelhash.test.ts b/packages/thirdweb/src/utils/ens/encodeLabelToLabelhash.test.ts new file mode 100644 index 00000000000..8a3a4a16c84 --- /dev/null +++ b/packages/thirdweb/src/utils/ens/encodeLabelToLabelhash.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { encodedLabelToLabelhash } from "./encodeLabelToLabelhash.js"; + +describe("encodedLabelToLabelhash", () => { + it("should return null if the label length is not 66", () => { + expect(encodedLabelToLabelhash("")).toBeNull(); + expect(encodedLabelToLabelhash("[123456]")).toBeNull(); + expect(encodedLabelToLabelhash("[1234567890]".padEnd(67, "0"))).toBeNull(); + }); + + it("should return null if the label does not start with '['", () => { + const input = "1234567890".padStart(66, "0"); + expect(encodedLabelToLabelhash(input)).toBeNull(); + }); + + it("should return null if the label does not end with ']' at position 65", () => { + const input = "[1234567890".padEnd(66, "0"); + expect(encodedLabelToLabelhash(input)).toBeNull(); + }); + + it("should return the hash if the label is valid", () => { + const validHash = "a".repeat(64); + const input = `[${validHash}]`; + + const result = encodedLabelToLabelhash(input); + expect(result).toBe(`0x${validHash}`); + }); +}); diff --git a/packages/thirdweb/src/utils/ens/encodeLabelhash.test.ts b/packages/thirdweb/src/utils/ens/encodeLabelhash.test.ts new file mode 100644 index 00000000000..7a3171ed82d --- /dev/null +++ b/packages/thirdweb/src/utils/ens/encodeLabelhash.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import type { Hex } from "../encoding/hex.js"; +import { encodeLabelhash } from "./encodeLabelhash.js"; + +describe("encodeLabelhash", () => { + it("should encode a valid hex hash correctly", () => { + const input = "0x1234567890abcdef"; + const expectedOutput = "[1234567890abcdef]"; + expect(encodeLabelhash(input)).toBe(expectedOutput); + }); + + it("should handle hashes of varying valid lengths", () => { + const shortHash = "0x1"; + const longHash = `0x${"a".repeat(64)}`; + expect(encodeLabelhash(shortHash)).toBe("[1]"); + expect(encodeLabelhash(longHash as Hex)).toBe(`[${"a".repeat(64)}]`); + }); +}); diff --git a/packages/thirdweb/src/utils/ens/namehash.test.ts b/packages/thirdweb/src/utils/ens/namehash.test.ts new file mode 100644 index 00000000000..d5cfcd3930c --- /dev/null +++ b/packages/thirdweb/src/utils/ens/namehash.test.ts @@ -0,0 +1,17 @@ +import { bytesToHex, concat } from "viem/utils"; +import { describe, expect, it } from "vitest"; +import { namehash } from "./namehash.js"; + +describe("namehash", () => { + it("should return a zero-filled hash for an empty name", () => { + const result = namehash(""); + expect(result).toBe(bytesToHex(new Uint8Array(32).fill(0))); + }); + + it("should correctly concatenate intermediate hashes", () => { + const labelBytes1 = new Uint8Array([1, 2, 3]); + const labelBytes2 = new Uint8Array([4, 5, 6]); + const concatenated = concat([labelBytes1, labelBytes2]); + expect(concat([labelBytes1, labelBytes2])).toEqual(concatenated); + }); +}); diff --git a/packages/thirdweb/src/utils/nft/parse-nft.test.ts b/packages/thirdweb/src/utils/nft/parseNft.test.ts similarity index 50% rename from packages/thirdweb/src/utils/nft/parse-nft.test.ts rename to packages/thirdweb/src/utils/nft/parseNft.test.ts index 46d20a18fe2..84ebb78d6dd 100644 --- a/packages/thirdweb/src/utils/nft/parse-nft.test.ts +++ b/packages/thirdweb/src/utils/nft/parseNft.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; import { type NFT, type NFTMetadata, type ParseNFTOptions, parseNFT, + parseNftUri, } from "./parseNft.js"; const base: NFTMetadata = { @@ -11,6 +13,8 @@ const base: NFTMetadata = { uri: "ipfs://", }; +const client = TEST_CLIENT; + describe("parseNft", () => { it("should parse ERC721 token", () => { const option: ParseNFTOptions = { @@ -57,4 +61,39 @@ describe("parseNft", () => { // @ts-ignore For testing purpose expect(() => parseNFT(base, option)).toThrowError(/Invalid NFT type/); }); + + it("should throw an error for an invalid EIP namespace", async () => { + const uri = "invalid:1/erc721:0x1234567890abcdef1234567890abcdef12345678/1"; + await expect(parseNftUri({ client, uri })).rejects.toThrow( + 'Invalid EIP namespace, expected EIP155, got: "invalid"', + ); + }); + + it("should throw an error for a missing chain ID", async () => { + const uri = "eip155:/erc721:0x1234567890abcdef1234567890abcdef12345678/1"; + await expect(parseNftUri({ client, uri })).rejects.toThrow( + "Chain ID not found", + ); + }); + + it("should throw an error for an invalid contract address", async () => { + const uri = "eip155:1/erc721:invalid-address/1"; + await expect(parseNftUri({ client, uri })).rejects.toThrow( + "Contract address not found", + ); + }); + + it("should throw an error for a missing token ID", async () => { + const uri = "eip155:1/erc721:0x1234567890abcdef1234567890abcdef12345678/"; + await expect(parseNftUri({ client, uri })).rejects.toThrow( + "Token ID not found", + ); + }); + + it("should throw an error for an invalid ERC namespace", async () => { + const uri = "eip155:1/invalid:0x1234567890abcdef1234567890abcdef12345678/1"; + await expect(parseNftUri({ client, uri })).rejects.toThrow( + 'Invalid ERC namespace, expected ERC721 or ERC1155, got: "invalid"', + ); + }); }); diff --git a/packages/thirdweb/src/utils/promise/p-limit.test.ts b/packages/thirdweb/src/utils/promise/p-limit.test.ts new file mode 100644 index 00000000000..62b9734c353 --- /dev/null +++ b/packages/thirdweb/src/utils/promise/p-limit.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { Queue, pLimit } from "./p-limit.js"; + +describe("p-limit queue", () => { + it("should enqueue and dequeue items in the correct order", () => { + const queue = new Queue(); + queue.enqueue(1); + queue.enqueue(2); + queue.enqueue(3); + + expect(queue.size).toBe(3); + expect(queue.dequeue()).toBe(1); + expect(queue.dequeue()).toBe(2); + expect(queue.dequeue()).toBe(3); + expect(queue.dequeue()).toBeUndefined(); + }); + + it("should correctly report size", () => { + const queue = new Queue(); + expect(queue.size).toBe(0); + + queue.enqueue(1); + expect(queue.size).toBe(1); + + queue.dequeue(); + expect(queue.size).toBe(0); + }); + + it("should clear the queue", () => { + const queue = new Queue(); + queue.enqueue(1); + queue.enqueue(2); + queue.clear(); + + expect(queue.size).toBe(0); + expect(queue.dequeue()).toBeUndefined(); + }); + + it("should support iteration", () => { + const queue = new Queue(); + queue.enqueue(1); + queue.enqueue(2); + queue.enqueue(3); + + const values = [...queue]; + expect(values).toEqual([1, 2, 3]); + }); +}); + +describe("pLimit", () => { + it("should limit the number of concurrent executions", async () => { + const limit = pLimit(2); + const executionOrder: number[] = []; + const delayedTask = (id: number) => + limit(async () => { + executionOrder.push(id); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await Promise.all([ + delayedTask(1), + delayedTask(2), + delayedTask(3), + delayedTask(4), + ]); + + // Ensure tasks were executed in the correct order with concurrency of 2 + expect(executionOrder).toEqual([1, 2, 3, 4]); + }); + + it("should handle rejected promises gracefully", async () => { + const limit = pLimit(1); + + await expect( + limit(async () => { + throw new Error("Test error"); + }), + ).rejects.toThrow("Test error"); + + expect(limit.activeCount).toBe(0); + expect(limit.pendingCount).toBe(0); + }); + + it("should handle mixed resolutions and rejections", async () => { + const limit = pLimit(2); + + const task1 = limit(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return 1; + }); + + const task2 = limit(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return 2; + }); + + const task3 = limit(async () => { + throw new Error("Task failed"); + }).catch(() => "Task failed"); + + const results = await Promise.all([task1, task2, task3]); + + expect(results).toStrictEqual([1, 2, "Task failed"]); + }); +}); diff --git a/packages/thirdweb/src/utils/promise/p-limit.ts b/packages/thirdweb/src/utils/promise/p-limit.ts index 089c77a190e..fdff51bf9fb 100644 --- a/packages/thirdweb/src/utils/promise/p-limit.ts +++ b/packages/thirdweb/src/utils/promise/p-limit.ts @@ -9,7 +9,10 @@ class Node { } } -class Queue { +/** + * @internal + */ +export class Queue { private head: Node | undefined; private tail: Node | undefined; size: number; diff --git a/packages/thirdweb/src/utils/promise/withCache.test.ts b/packages/thirdweb/src/utils/promise/withCache.test.ts index 6db1448ed5f..ac1ff8499c2 100644 --- a/packages/thirdweb/src/utils/promise/withCache.test.ts +++ b/packages/thirdweb/src/utils/promise/withCache.test.ts @@ -95,3 +95,76 @@ it("behavior: programmatic removal", async () => { expect(fn).toBeCalledTimes(2); }); + +describe("getCache", () => { + it("should return an object with clear, get, and set methods", () => { + const cacheKey = "testKey"; + const cache = getCache(cacheKey); + + expect(typeof cache.clear).toBe("function"); + expect(typeof cache.promise.get).toBe("function"); + expect(typeof cache.promise.set).toBe("function"); + expect(typeof cache.response.get).toBe("function"); + expect(typeof cache.response.set).toBe("function"); + }); + + it("should allow setting and getting values from the promise cache", () => { + const cacheKey = "testKey"; + const cache = getCache(cacheKey); + const testPromise = Promise.resolve("testValue"); + + cache.promise.set(testPromise); + expect(cache.promise.get()).toBe(testPromise); + }); + + it("should allow clearing the cache", () => { + const cacheKey = "testKey"; + const cache = getCache(cacheKey); + const testPromise = Promise.resolve("testValue"); + + cache.promise.set(testPromise); + cache.clear(); + expect(cache.promise.get()).toBeUndefined(); + }); +}); + +describe("withCache", () => { + it("should execute the function and cache the result", async () => { + const cacheKey = "testKey1"; + const fn = vi.fn().mockResolvedValue("testValue"); + + const result1 = await withCache(fn, { cacheKey }); + const result2 = await withCache(fn, { cacheKey }); + + expect(result1).toBe("testValue"); + expect(result2).toBe("testValue"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("should not return expired cache data", async () => { + const cacheKey = "testKey2"; + const fn = vi.fn().mockResolvedValue("newValue"); + + const result1 = await withCache(fn, { cacheKey, cacheTime: 10 }); + await new Promise((resolve) => setTimeout(resolve, 20)); // Wait for cache to expire + const result2 = await withCache(fn, { cacheKey, cacheTime: 10 }); + + expect(result1).toBe("newValue"); + expect(result2).toBe("newValue"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("should deduplicate concurrent calls to the same cache key", async () => { + const cacheKey = "testKey3"; + const fn = vi.fn().mockResolvedValue("testValue"); + + const [result1, result2] = await Promise.all([ + withCache(fn, { cacheKey, cacheTime: 100 }), + withCache(fn, { cacheKey, cacheTime: 100 }), + ]); + + expect(result1).toBe("testValue"); + expect(result2).toBe("testValue"); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/thirdweb/src/utils/semver.test.ts b/packages/thirdweb/src/utils/semver.test.ts new file mode 100644 index 00000000000..32bcaee1291 --- /dev/null +++ b/packages/thirdweb/src/utils/semver.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { isIncrementalVersion, toSemver } from "./semver.js"; + +describe("toSemver", () => { + it("should parse a valid semantic version", () => { + const result = toSemver("1.2.3"); + expect(result).toEqual({ + major: 1, + minor: 2, + patch: 3, + versionString: "1.2.3", + }); + }); + + it("should handle versions with leading/trailing spaces", () => { + const result = toSemver(" 1.0.0 "); + expect(result).toEqual({ + major: 1, + minor: 0, + patch: 0, + versionString: "1.0.0", + }); + }); + + it("should throw an error for versions exceeding max length", () => { + const longVersion = "1".repeat(257); // 257 characters + expect(() => toSemver(longVersion)).toThrow( + "version is longer than 256 characters", + ); + }); + + it("should throw an error for invalid semantic version formats", () => { + const invalidVersions = ["testd", "1....2", "test.1.0"]; + for (const version of invalidVersions) { + expect(() => toSemver(version)).toThrowError( + `${version} is not a valid semantic version. Should be in the format of major.minor.patch. Ex: 0.4.1`, + ); + } + }); +}); + +describe("isIncrementalVersion", () => { + it("should return true for valid incremental versions", () => { + expect(isIncrementalVersion("1.0.0", "2.0.0")).toBe(true); // Major increment + expect(isIncrementalVersion("1.0.0", "1.1.0")).toBe(true); // Minor increment + expect(isIncrementalVersion("1.1.0", "1.1.1")).toBe(true); // Patch increment + }); + + it("should return false for non-incremental versions", () => { + expect(isIncrementalVersion("2.0.0", "1.0.0")).toBe(false); // Major decrement + expect(isIncrementalVersion("1.1.0", "1.0.0")).toBe(false); // Minor decrement + expect(isIncrementalVersion("1.1.1", "1.1.0")).toBe(false); // Patch decrement + expect(isIncrementalVersion("1.1.1", "1.1.1")).toBe(false); // Same version + }); + + it("should handle whitespace around version strings", () => { + expect(isIncrementalVersion(" 1.0.0", "2.0.0 ")).toBe(true); + }); + + it("should throw errors for invalid version inputs", () => { + expect(() => isIncrementalVersion("invalid", "1.0.0")).toThrow(); + expect(() => isIncrementalVersion("1.0.0", "invalid")).toThrow(); + expect(() => isIncrementalVersion("invalid", "invalid")).toThrow(); + }); + + it("should correctly handle complex version comparisons", () => { + expect(isIncrementalVersion("1.0.0", "1.1.0")).toBe(true); + expect(isIncrementalVersion("1.1.0", "1.1.1")).toBe(true); + expect(isIncrementalVersion("1.1.1", "2.0.0")).toBe(true); + expect(isIncrementalVersion("1.1.1", "0.9.9")).toBe(false); + }); +}); diff --git a/packages/thirdweb/src/utils/semver.ts b/packages/thirdweb/src/utils/semver.ts index c2735cbb5a2..a79ab47b039 100644 --- a/packages/thirdweb/src/utils/semver.ts +++ b/packages/thirdweb/src/utils/semver.ts @@ -17,7 +17,7 @@ type Semver = { * @internal * @param version - The version to convert to a Semver */ -function toSemver(version: string): Semver { +export function toSemver(version: string): Semver { if (version.length > MAX_LENGTH) { throw new Error(`version is longer than ${MAX_LENGTH} characters`); } diff --git a/packages/thirdweb/src/utils/url.test.ts b/packages/thirdweb/src/utils/url.test.ts index 7b8dc00b64d..b74c6ea71bc 100644 --- a/packages/thirdweb/src/utils/url.test.ts +++ b/packages/thirdweb/src/utils/url.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { formatWalletConnectUrl } from "./url.js"; +import { + formatExplorerAddressUrl, + formatExplorerTxUrl, + formatNativeUrl, + formatUniversalUrl, + formatWalletConnectUrl, + isHttpUrl, +} from "./url.js"; describe("formatWalletConnectUrl", () => { it("should format a wallet connect URL for an HTTP app URL", () => { @@ -30,3 +37,85 @@ describe("formatWalletConnectUrl", () => { `); }); }); + +describe("Utility functions tests", () => { + describe("isHttpUrl", () => { + it("should return true for valid HTTP/HTTPS URLs", () => { + expect(isHttpUrl("http://example.com")).toBe(true); + expect(isHttpUrl("https://example.com")).toBe(true); + }); + + it("should return false for non-HTTP URLs", () => { + expect(isHttpUrl("ftp://example.com")).toBe(false); + expect(isHttpUrl("example://custom")).toBe(false); + expect(isHttpUrl("example.com")).toBe(false); + }); + }); + + describe("formatUniversalUrl", () => { + it("should format a valid HTTP URL with a WalletConnect URI", () => { + const result = formatUniversalUrl("https://example.com", "wc:uri"); + expect(result).toEqual({ + redirect: "https://example.com/wc?uri=wc%3Auri", + href: "https://example.com/", + }); + }); + }); + + describe("formatNativeUrl", () => { + it("should format a valid native URL with a WalletConnect URI", () => { + const result = formatNativeUrl("custom://example", "wc:uri"); + expect(result).toEqual({ + redirect: "custom://example/wc?uri=wc%3Auri", + href: "custom://example/", + }); + }); + }); + + describe("formatWalletConnectUrl", () => { + it("should call formatUniversalUrl for HTTP URLs", () => { + const result = formatWalletConnectUrl("https://example.com", "wc:uri"); + expect(result).toEqual({ + redirect: "https://example.com/wc?uri=wc%3Auri", + href: "https://example.com/", + }); + }); + + it("should call formatNativeUrl for non-HTTP URLs", () => { + const result = formatWalletConnectUrl("custom://example", "wc:uri"); + expect(result).toEqual({ + redirect: "custom://example/wc?uri=wc%3Auri", + href: "custom://example/", + }); + }); + }); + + describe("formatExplorerTxUrl", () => { + it("should correctly format transaction URLs", () => { + const result = formatExplorerTxUrl("https://explorer.com", "tx123"); + expect(result).toBe("https://explorer.com/tx/tx123"); + + const resultWithSlash = formatExplorerTxUrl( + "https://explorer.com/", + "tx123", + ); + expect(resultWithSlash).toBe("https://explorer.com/tx/tx123"); + }); + }); + + describe("formatExplorerAddressUrl", () => { + it("should correctly format address URLs", () => { + const result = formatExplorerAddressUrl( + "https://explorer.com", + "addr123", + ); + expect(result).toBe("https://explorer.com/address/addr123"); + + const resultWithSlash = formatExplorerAddressUrl( + "https://explorer.com/", + "addr123", + ); + expect(resultWithSlash).toBe("https://explorer.com/address/addr123"); + }); + }); +}); diff --git a/packages/thirdweb/src/utils/url.ts b/packages/thirdweb/src/utils/url.ts index 550b3be8386..51516916989 100644 --- a/packages/thirdweb/src/utils/url.ts +++ b/packages/thirdweb/src/utils/url.ts @@ -1,7 +1,7 @@ /** * @internal */ -function isHttpUrl(url: string) { +export function isHttpUrl(url: string) { return url.startsWith("http://") || url.startsWith("https://"); } @@ -13,7 +13,10 @@ type LinkingRecord = { /** * @internal */ -function formatUniversalUrl(appUrl: string, wcUri: string): LinkingRecord { +export function formatUniversalUrl( + appUrl: string, + wcUri: string, +): LinkingRecord { if (!isHttpUrl(appUrl)) { return formatNativeUrl(appUrl, wcUri); } @@ -32,7 +35,7 @@ function formatUniversalUrl(appUrl: string, wcUri: string): LinkingRecord { /** * @internal */ -function formatNativeUrl(appUrl: string, wcUri: string): LinkingRecord { +export function formatNativeUrl(appUrl: string, wcUri: string): LinkingRecord { if (isHttpUrl(appUrl)) { return formatUniversalUrl(appUrl, wcUri); } diff --git a/packages/thirdweb/src/wallets/eip5792/get-calls-status.test.ts b/packages/thirdweb/src/wallets/eip5792/get-calls-status.test.ts index 021aab45738..61303d22033 100644 --- a/packages/thirdweb/src/wallets/eip5792/get-calls-status.test.ts +++ b/packages/thirdweb/src/wallets/eip5792/get-calls-status.test.ts @@ -92,7 +92,7 @@ describe.sequential("injected wallet", async () => { bundleId: "test", }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( `[Error: Failed to get call status, no account found for wallet ${wallet.id}]`, ); }); @@ -130,7 +130,7 @@ describe.sequential("injected wallet", async () => { bundleId: "test", }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: io.metamask does not support wallet_getCallsStatus, reach out to them directly to request EIP-5792 support.]", ); }); @@ -224,7 +224,7 @@ describe.sequential("in-app wallet", async () => { bundleId: "unknown", }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: Failed to get calls status, unknown bundle id]", ); }); @@ -238,7 +238,7 @@ describe.sequential("in-app wallet", async () => { bundleId: "test", }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: Failed to get calls status, no active chain found]", ); }); diff --git a/packages/thirdweb/src/wallets/eip5792/send-calls.test.ts b/packages/thirdweb/src/wallets/eip5792/send-calls.test.ts index aed7809bb4a..d53b20044b1 100644 --- a/packages/thirdweb/src/wallets/eip5792/send-calls.test.ts +++ b/packages/thirdweb/src/wallets/eip5792/send-calls.test.ts @@ -85,22 +85,22 @@ describe.sequential("injected wallet", () => { vi.clearAllMocks(); }); - test("with no chain should fail to send calls", () => { + test("with no chain should fail to send calls", async () => { wallet.getChain = vi.fn().mockReturnValue(undefined); wallet.getAccount = vi.fn().mockReturnValue(TEST_ACCOUNT_A); const promise = sendCalls({ wallet, ...SEND_CALLS_OPTIONS }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: Cannot send calls, no active chain found for wallet: io.metamask]", ); }); - test("with no account should fail to send calls", () => { + test("with no account should fail to send calls", async () => { wallet.getChain = vi.fn().mockReturnValue(ANVIL_CHAIN); wallet.getAccount = vi.fn().mockReturnValue(undefined); const promise = sendCalls({ wallet, ...SEND_CALLS_OPTIONS }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: Cannot send calls, no account connected for wallet: io.metamask]", ); }); @@ -227,7 +227,7 @@ describe.sequential("injected wallet", () => { ...SEND_CALLS_OPTIONS, }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: io.metamask does not support wallet_sendCalls, reach out to them directly to request EIP-5792 support.]", ); }); @@ -252,7 +252,7 @@ describe.sequential("in-app wallet", () => { test("without account should fail", async () => { wallet.getAccount = vi.fn().mockReturnValue(undefined); const promise = sendCalls({ wallet, ...SEND_CALLS_OPTIONS }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: Cannot send calls, no account connected for wallet: inApp]", ); }); @@ -260,7 +260,7 @@ describe.sequential("in-app wallet", () => { test("without account should fail", async () => { wallet.getAccount = vi.fn().mockReturnValue(undefined); const promise = sendCalls({ wallet, ...SEND_CALLS_OPTIONS }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: Cannot send calls, no account connected for wallet: inApp]", ); }); diff --git a/packages/thirdweb/src/wallets/eip5792/show-calls-status.test.ts b/packages/thirdweb/src/wallets/eip5792/show-calls-status.test.ts index e4bbefdecbd..f33a2f44fb3 100644 --- a/packages/thirdweb/src/wallets/eip5792/show-calls-status.test.ts +++ b/packages/thirdweb/src/wallets/eip5792/show-calls-status.test.ts @@ -41,7 +41,7 @@ describe.sequential("injected wallet", async () => { bundleId: "test", }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: io.metamask does not support wallet_showCallsStatus, reach out to them directly to request EIP-5792 support.]", ); }); @@ -56,7 +56,7 @@ describe.sequential("other wallets", async () => { bundleId: "test", }); - expect(promise).rejects.toMatchInlineSnapshot( + await expect(promise).rejects.toMatchInlineSnapshot( "[Error: showCallsStatus is currently unsupported for this wallet type]", ); });