diff --git a/src/common/Config.ts b/src/common/Config.ts index 32977052e4..b36969dd89 100644 --- a/src/common/Config.ts +++ b/src/common/Config.ts @@ -1,7 +1,7 @@ import winston from "winston"; import { DEFAULT_MULTICALL_CHUNK_SIZE, DEFAULT_ARWEAVE_GATEWAY } from "../common"; import { ArweaveGatewayInterface, ArweaveGatewayInterfaceSS } from "../interfaces"; -import { addressAdapters, AddressAggregator, assert, CHAIN_IDs, isDefined } from "../utils"; +import { addressAdapters, AddressAggregator, assert, CHAIN_IDs, isDefined, parseJson } from "../utils"; import * as Constants from "./Constants"; export interface ProcessEnv { @@ -79,8 +79,8 @@ export class CommonConfig { // `maxRelayerLookBack` is how far we fetch events from, modifying the search config's 'fromBlock' this.maxRelayerLookBack = Number(MAX_RELAYER_DEPOSIT_LOOK_BACK ?? Constants.MAX_RELAYER_DEPOSIT_LOOK_BACK); this.pollingDelay = Number(POLLING_DELAY ?? 60); - this.spokePoolChainsOverride = JSON.parse(SPOKE_POOL_CHAINS_OVERRIDE ?? "[]"); - this.l1TokensOverride = JSON.parse(L1_TOKENS_OVERRIDE ?? "[]"); + this.spokePoolChainsOverride = parseJson.numberArray(SPOKE_POOL_CHAINS_OVERRIDE); + this.l1TokensOverride = parseJson.stringArray(L1_TOKENS_OVERRIDE); // Inherit the default eth_getLogs block range config, then sub in any env-based overrides. this.maxBlockLookBack = mergeConfig(Constants.CHAIN_MAX_BLOCK_LOOKBACK, MAX_BLOCK_LOOK_BACK); @@ -93,9 +93,9 @@ export class CommonConfig { this.arweaveGateway = _arweaveGateway; this.peggedTokenPrices = Object.fromEntries( - Object.entries(JSON.parse(PEGGED_TOKEN_PRICES ?? "{}")).map(([pegTokenSymbol, tokenSymbolsToPeg]) => [ + Object.entries(parseJson.stringArrayMap(PEGGED_TOKEN_PRICES)).map(([pegTokenSymbol, tokenSymbolsToPeg]) => [ pegTokenSymbol, - new Set(tokenSymbolsToPeg as string[]), + new Set(tokenSymbolsToPeg), ]) ); } diff --git a/src/dataworker/DataworkerConfig.ts b/src/dataworker/DataworkerConfig.ts index 6a843faa35..92e5a3d0d2 100644 --- a/src/dataworker/DataworkerConfig.ts +++ b/src/dataworker/DataworkerConfig.ts @@ -1,5 +1,5 @@ import { CommonConfig, ProcessEnv } from "../common"; -import { assert, getArweaveJWKSigner } from "../utils"; +import { assert, getArweaveJWKSigner, parseJson } from "../utils"; export class DataworkerConfig extends CommonConfig { readonly minChallengeLeadTime: number; @@ -83,7 +83,7 @@ export class DataworkerConfig extends CommonConfig { this.proposerEnabled = PROPOSER_ENABLED === "true"; this.l2ExecutorEnabled = L2_EXECUTOR_ENABLED === "true"; this.l1ExecutorEnabled = L1_EXECUTOR_ENABLED === "true"; - this.executorIgnoreChains = JSON.parse(EXECUTOR_IGNORE_CHAINS ?? "[]"); + this.executorIgnoreChains = parseJson.numberArray(EXECUTOR_IGNORE_CHAINS); if (this.l2ExecutorEnabled) { assert(this.spokeRootsLookbackCount > 0, "must set spokeRootsLookbackCount > 0 if L2 executor enabled"); } else if (this.disputerEnabled || this.proposerEnabled) { diff --git a/src/deposit-address/DepositAddressHandler.ts b/src/deposit-address/DepositAddressHandler.ts index c7fdbfd231..e752af45af 100644 --- a/src/deposit-address/DepositAddressHandler.ts +++ b/src/deposit-address/DepositAddressHandler.ts @@ -3,6 +3,7 @@ import { DepositAddressHandlerConfig } from "./DepositAddressHandlerConfig"; import { getRedisCache, isDefined, + parseJson, Signer, scheduleTask, Provider, @@ -134,8 +135,7 @@ export class DepositAddressHandler { let arr: string[] = []; try { if (raw) { - const parsed = JSON.parse(raw); - arr = Array.isArray(parsed) ? parsed : []; + arr = parseJson.stringArray(raw); } } catch (err) { this.logger.error({ diff --git a/src/deposit-address/DepositAddressHandlerConfig.ts b/src/deposit-address/DepositAddressHandlerConfig.ts index bc20b8b6d5..cae9682f30 100644 --- a/src/deposit-address/DepositAddressHandlerConfig.ts +++ b/src/deposit-address/DepositAddressHandlerConfig.ts @@ -1,4 +1,5 @@ import { CommonConfig, ProcessEnv } from "../common"; +import { parseJson } from "../utils"; export class DepositAddressHandlerConfig extends CommonConfig { apiEndpoint: string; @@ -33,7 +34,7 @@ export class DepositAddressHandlerConfig extends CommonConfig { throw new Error("SWAP_API_KEY is required (set SWAP_API_KEY in env)"); } - const relayerOriginChains = new Set(JSON.parse(RELAYER_ORIGIN_CHAINS ?? "[]")); + const relayerOriginChains = new Set(parseJson.numberArray(RELAYER_ORIGIN_CHAINS)); this.relayerOriginChains = Array.from(relayerOriginChains); this.depositLookback = Number(MAX_RELAYER_DEPOSIT_LOOKBACK ?? 3600); diff --git a/src/finalizer/config.ts b/src/finalizer/config.ts index 7813a063ae..879bf65735 100644 --- a/src/finalizer/config.ts +++ b/src/finalizer/config.ts @@ -1,7 +1,7 @@ import assert from "assert"; import { assert as ssAssert, enums } from "superstruct"; import { CommonConfig, FINALIZER_TOKENBRIDGE_LOOKBACK, ProcessEnv } from "../common"; -import { Address, EvmAddress, SvmAddress, ethers } from "../utils"; +import { Address, EvmAddress, SvmAddress, ethers, parseJson } from "../utils"; /** * The finalization type is used to determine the direction of the finalization. @@ -19,13 +19,13 @@ export class FinalizerConfig extends CommonConfig { const { FINALIZER_MAX_TOKENBRIDGE_LOOKBACK, FINALIZER_CHAINS = "[]", - FINALIZER_WITHDRAWAL_TO_ADDRESSES = "[]", + FINALIZER_WITHDRAWAL_TO_ADDRESSES = "{}", FINALIZATION_STRATEGY = "l1<->l2", FINALIZER_CHUNK_SIZE = 3, } = env; super(env); - const userAddresses: { [address: string]: string[] } = JSON.parse(FINALIZER_WITHDRAWAL_TO_ADDRESSES); + const userAddresses = parseJson.stringArrayMap(FINALIZER_WITHDRAWAL_TO_ADDRESSES); this.userAddresses = new Map(); Object.entries(userAddresses).forEach(([address, tokensToFinalize]) => { if (ethers.utils.isHexString(address)) { @@ -35,7 +35,7 @@ export class FinalizerConfig extends CommonConfig { } }); - this.chainsToFinalize = JSON.parse(FINALIZER_CHAINS); + this.chainsToFinalize = parseJson.numberArray(FINALIZER_CHAINS); // `maxFinalizerLookback` is how far we fetch events from, modifying the search config's 'fromBlock' this.maxFinalizerLookback = Number(FINALIZER_MAX_TOKENBRIDGE_LOOKBACK ?? FINALIZER_TOKENBRIDGE_LOOKBACK); diff --git a/src/gasless/GaslessRelayerConfig.ts b/src/gasless/GaslessRelayerConfig.ts index c542e86b1f..a8ea3e904b 100644 --- a/src/gasless/GaslessRelayerConfig.ts +++ b/src/gasless/GaslessRelayerConfig.ts @@ -1,4 +1,6 @@ +import assert from "assert"; import { CommonConfig, ProcessEnv } from "../common"; +import { isDefined, parseJson } from "../utils"; /** * Allowed pegged token pairs for gasless deposits/fills. Same shape as PEGGED_TOKEN_PRICES: @@ -42,24 +44,25 @@ export class GaslessRelayerConfig extends CommonConfig { this.apiPollingInterval = Number(API_POLLING_INTERVAL ?? 1); // Default to 1s this.apiEndpoint = String(API_GASLESS_ENDPOINT); - const relayerOriginChains = new Set(JSON.parse(RELAYER_ORIGIN_CHAINS ?? "[]")); + const relayerOriginChains = new Set(parseJson.numberArray(RELAYER_ORIGIN_CHAINS)); this.relayerOriginChains = Array.from(relayerOriginChains); - const relayerDestinationChains = new Set(JSON.parse(RELAYER_DESTINATION_CHAINS ?? "[]")); + const relayerDestinationChains = new Set(parseJson.numberArray(RELAYER_DESTINATION_CHAINS)); this.relayerDestinationChains = Array.from(relayerDestinationChains); - this.relayerTokenSymbols = JSON.parse(RELAYER_TOKEN_SYMBOLS); // Relayer token symbols must be defined. + assert(isDefined(RELAYER_TOKEN_SYMBOLS), "RELAYER_TOKEN_SYMBOLS must be defined"); + this.relayerTokenSymbols = parseJson.stringArray(RELAYER_TOKEN_SYMBOLS); this.depositLookback = Number(MAX_RELAYER_DEPOSIT_LOOKBACK ?? 3600); this.apiTimeoutOverride = Number(API_TIMEOUT_OVERRIDE ?? 3000); // In ms this.initializationRetryAttempts = Number(INITIALIZATION_RETRY_ATTEMPTS ?? 3); this.refundFlowTestEnabled = String(RELAYER_GASLESS_REFUND_FLOW_TEST_ENABLED ?? "").toLowerCase() === "true"; - this.spokePoolPeripheryOverrides = JSON.parse(SPOKE_POOL_PERIPHERY_OVERRIDES ?? "{}"); + this.spokePoolPeripheryOverrides = parseJson.stringMap(SPOKE_POOL_PERIPHERY_OVERRIDES); this.allowedPeggedPairs = Object.fromEntries( - Object.entries(JSON.parse(GASLESS_ALLOWED_PEGGED_PAIRS ?? "{}")).map(([inputSymbol, outputSymbols]) => [ + Object.entries(parseJson.stringArrayMap(GASLESS_ALLOWED_PEGGED_PAIRS)).map(([inputSymbol, outputSymbols]) => [ inputSymbol, - new Set(outputSymbols as string[]), + new Set(outputSymbols), ]) ); } diff --git a/src/hyperliquid/HyperliquidExecutorConfig.ts b/src/hyperliquid/HyperliquidExecutorConfig.ts index 139b161127..049257a95c 100644 --- a/src/hyperliquid/HyperliquidExecutorConfig.ts +++ b/src/hyperliquid/HyperliquidExecutorConfig.ts @@ -1,4 +1,4 @@ -import { toBNWei, BigNumber } from "../utils"; +import { toBNWei, BigNumber, parseJson } from "../utils"; import { CommonConfig, ProcessEnv } from "../common"; export class HyperliquidExecutorConfig extends CommonConfig { @@ -20,13 +20,13 @@ export class HyperliquidExecutorConfig extends CommonConfig { HYPERLIQUID_SETTLEMENT_INTERVAL = 5, // blocks MAX_SLIPPAGE_BY_ROUTE_BPS, } = env; - this.supportedTokens = JSON.parse(HYPERLIQUID_SUPPORTED_TOKENS ?? "[]"); + this.supportedTokens = parseJson.stringArray(HYPERLIQUID_SUPPORTED_TOKENS); this.lookback = Number(HL_DEPOSIT_LOOKBACK ?? 3600); this.reviewInterval = Number(HYPERLIQUID_REPLACE_ORDER_BLOCK_TIMEOUT ?? 20); this.maxSlippageBps = toBNWei(Number(MAX_SLIPPAGE_BPS ?? 5), 4); // With 8 decimal precision, a basis point is 10000. Default to 5bps. this.settlementInterval = Number(HYPERLIQUID_SETTLEMENT_INTERVAL); - const maxSlippageByRoute = JSON.parse(MAX_SLIPPAGE_BY_ROUTE_BPS ?? "{}"); + const maxSlippageByRoute = parseJson.numericMap(MAX_SLIPPAGE_BY_ROUTE_BPS); Object.entries(maxSlippageByRoute).forEach(([pairName, value]) => { this.maxSlippageByRoute[pairName] = toBNWei(Number(value), 4); }); diff --git a/src/monitor/MonitorConfig.ts b/src/monitor/MonitorConfig.ts index bc54c01c5c..73d135ee39 100644 --- a/src/monitor/MonitorConfig.ts +++ b/src/monitor/MonitorConfig.ts @@ -7,6 +7,7 @@ import { TOKEN_SYMBOLS_MAP, Address, toAddressType, + parseJson, } from "../utils"; // Set modes to true that you want to enable in the AcrossMonitor bot. @@ -104,19 +105,18 @@ export class MonitorConfig extends CommonConfig { this.whitelistedRelayers = parseAddressesOptional(WHITELISTED_RELAYERS); this.monitoredRelayers = parseAddressesOptional(MONITORED_RELAYERS); this.knownV1Addresses = parseAddressesOptional(KNOWN_V1_ADDRESSES); - this.monitoredSpokePoolChains = JSON.parse(MONITORED_SPOKE_POOL_CHAINS ?? "[]"); - this.monitoredTokenSymbols = JSON.parse(MONITORED_TOKEN_SYMBOLS ?? "[]"); + this.monitoredSpokePoolChains = parseJson.numberArray(MONITORED_SPOKE_POOL_CHAINS); + this.monitoredTokenSymbols = parseJson.stringArray(MONITORED_TOKEN_SYMBOLS); this.bundlesCount = Number(BUNDLES_COUNT ?? 4); - this.additionalL1NonLpTokens = JSON.parse(MONITOR_REPORT_NON_LP_TOKENS ?? "[]").map((token) => { - if (TOKEN_SYMBOLS_MAP[token]?.addresses?.[CHAIN_IDs.MAINNET]) { - return TOKEN_SYMBOLS_MAP[token]?.addresses?.[CHAIN_IDs.MAINNET]; - } - }); + this.additionalL1NonLpTokens = parseJson + .stringArray(MONITOR_REPORT_NON_LP_TOKENS) + .filter((token) => TOKEN_SYMBOLS_MAP[token]?.addresses[CHAIN_IDs.MAINNET]) + .map((token) => TOKEN_SYMBOLS_MAP[token].addresses[CHAIN_IDs.MAINNET]); this.binanceWithdrawWarnThreshold = Number(BINANCE_WITHDRAW_WARN_THRESHOLD ?? 1); this.binanceWithdrawAlertThreshold = Number(BINANCE_WITHDRAW_ALERT_THRESHOLD ?? 1); - this.hyperliquidTokens = JSON.parse(HYPERLIQUID_SUPPORTED_TOKENS ?? '["USDC", "USDT0", "USDH"]'); + this.hyperliquidTokens = parseJson.stringArray(HYPERLIQUID_SUPPORTED_TOKENS ?? '["USDC", "USDT0", "USDH"]'); this.hyperliquidOrderMaximumLifetime = Number(HYPERLIQUID_ORDER_MAXIMUM_LIFETIME ?? -1); // Default pool utilization threshold at 90%. @@ -183,8 +183,10 @@ export class MonitorConfig extends CommonConfig { } const parseAddressesOptional = (addressJson?: string): Address[] => { - const rawAddresses: string[] = addressJson ? JSON.parse(addressJson) : []; - return rawAddresses.map((address) => { + if (!addressJson) { + return []; + } + return parseJson.stringArray(addressJson).map((address) => { const chainId = address.startsWith("0x") ? CHAIN_IDs.MAINNET : CHAIN_IDs.SOLANA; return toAddressType(address, chainId); }); diff --git a/src/relayer/RelayerConfig.ts b/src/relayer/RelayerConfig.ts index cd8bbac400..e7b32d61b7 100644 --- a/src/relayer/RelayerConfig.ts +++ b/src/relayer/RelayerConfig.ts @@ -19,6 +19,7 @@ import { Address, toAddressType, EvmAddress, + parseJson, } from "../utils"; import { CommonConfig, ProcessEnv } from "../common"; import * as Constants from "../common/Constants"; @@ -107,32 +108,30 @@ export class RelayerConfig extends CommonConfig { this.eventListener = this.externalListener && RELAYER_EVENT_LISTENER === "true"; // Empty means all chains. - this.relayerOriginChains = JSON.parse(RELAYER_ORIGIN_CHAINS ?? "[]"); - this.relayerDestinationChains = JSON.parse(RELAYER_DESTINATION_CHAINS ?? "[]"); + this.relayerOriginChains = parseJson.numberArray(RELAYER_ORIGIN_CHAINS); + this.relayerDestinationChains = parseJson.numberArray(RELAYER_DESTINATION_CHAINS); // Empty means all tokens. - this.relayerTokens = JSON.parse(RELAYER_TOKENS ?? "[]").map((token) => - toAddressType(ethers.utils.getAddress(token), CHAIN_IDs.MAINNET) - ); + this.relayerTokens = parseJson.stringArray(RELAYER_TOKENS).map((token) => EvmAddress.from(token)); // An empty array for a defined destination chain means that all tokens are supported. To support no tokens // for a destination chain, map the chain to an empty array. For example, to fill only token A on chain C // and fill nothing on chain D, set relayerDestinationTokens: { C: [A], D: [] } this.relayerDestinationTokens = Object.fromEntries( - Object.entries(JSON.parse(RELAYER_DESTINATION_TOKENS ?? "{}")).map(([_chainId, tokens]) => { + Object.entries(parseJson.stringArrayMap(RELAYER_DESTINATION_TOKENS)).map(([_chainId, tokens]) => { const chainId = Number(_chainId); - return [chainId, ((tokens as string[]) ?? []).map((token) => toAddressType(token, Number(chainId)))]; + return [chainId, tokens.map((token) => toAddressType(token, chainId))]; }) ); // SLOW_DEPOSITORS can exist on any network, so their origin network must be inferred based on the structure of the address. - this.slowDepositors = JSON.parse(SLOW_DEPOSITORS ?? "[]").map((depositor) => { + this.slowDepositors = parseJson.stringArray(SLOW_DEPOSITORS).map((depositor) => { const chainId = ethers.utils.isHexString(depositor) ? CHAIN_IDs.MAINNET : CHAIN_IDs.SOLANA; return toAddressType(depositor, chainId); }); this.minRelayerFeePct = toBNWei(MIN_RELAYER_FEE_PCT || Constants.RELAYER_MIN_FEE_PCT); - this.tryMulticallChains = JSON.parse(RELAYER_TRY_MULTICALL_CHAINS ?? "[]"); + this.tryMulticallChains = parseJson.numberArray(RELAYER_TRY_MULTICALL_CHAINS); this.loggingInterval = Number(RELAYER_LOGGING_INTERVAL); this.maintenanceInterval = Number(RELAYER_MAINTENANCE_INTERVAL); diff --git a/src/utils/SignerUtils.ts b/src/utils/SignerUtils.ts index 7859827e16..070caa2ea8 100644 --- a/src/utils/SignerUtils.ts +++ b/src/utils/SignerUtils.ts @@ -2,7 +2,7 @@ import { readFile } from "fs/promises"; import { constants as ethersConsts, VoidSigner } from "ethers"; import minimist from "minimist"; import { typeguards } from "@across-protocol/sdk"; -import { Signer, Wallet, retrieveGckmsKeys, getGckmsConfig, isDefined, assert, mapAsync } from "./"; +import { Signer, Wallet, retrieveGckmsKeys, getGckmsConfig, isDefined, assert, mapAsync, parseJson } from "./"; import { ArweaveWalletJWKInterface, ArweaveWalletJWKInterfaceSS } from "../interfaces"; /** @@ -183,7 +183,7 @@ export async function getDispatcherKeys(): Promise { const args = minimist(process.argv.slice(2), opts); if (!isDefined(args.dispatcherKeys)) { // If GCKMS keys are undefined. Assume keys are in environment. - const keys = JSON.parse(process.env["DISPATCHER_KEYS"] ?? "[]"); + const keys = parseJson.stringArray(process.env.DISPATCHER_KEYS); return keys.map((key) => new Wallet(key)); } const dispatcherKeyNames = args.dispatcherKeys.split(","); diff --git a/src/utils/TypeGuards.ts b/src/utils/TypeGuards.ts index cc57d74d57..23e539f460 100644 --- a/src/utils/TypeGuards.ts +++ b/src/utils/TypeGuards.ts @@ -1,7 +1,47 @@ +import { array, create, number, record, string, union } from "superstruct"; import { utils } from "@across-protocol/sdk"; export const { isDefined, isPromiseFulfilled, isPromiseRejected } = utils; +/** + * Typed JSON.parse helpers. Validates the parsed result against a superstruct schema + * and returns the correctly-typed value. Throws on validation failure. + * + * Usage: + * parseJson.stringArray(env.FOO) + * parseJson.numberArray(env.BAR) + * parseJson.stringArrayMap(env.BAZ) + * parseJson.stringMap(env.QUX) + * parseJson.numberMap(env.QUUX) + * parseJson.numericMap(env.CORGE) + */ +export const parseJson = { + /** Parse a JSON string expected to contain a string[]. */ + stringArray(json = "[]"): string[] { + return create(JSON.parse(json), array(string())); + }, + /** Parse a JSON string expected to contain a number[]. */ + numberArray(json = "[]"): number[] { + return create(JSON.parse(json), array(number())); + }, + /** Parse a JSON string expected to contain a Record. */ + stringMap(json = "{}"): Record { + return create(JSON.parse(json), record(string(), string())); + }, + /** Parse a JSON string expected to contain a Record. */ + numberMap(json = "{}"): Record { + return create(JSON.parse(json), record(string(), number())); + }, + /** Parse a JSON string expected to contain a Record. */ + numericMap(json = "{}"): Record { + return create(JSON.parse(json), record(string(), union([string(), number()]))); + }, + /** Parse a JSON string expected to contain a Record. */ + stringArrayMap(json = "{}"): Record { + return create(JSON.parse(json), record(string(), array(string()))); + }, +}; + // This function allows you to test for the key type in an object literal. // For instance, this would compile in typescript strict: // const myObj = { a: 1, b: 2, c: 3 } as const; diff --git a/test/TypeGuards.ts b/test/TypeGuards.ts new file mode 100644 index 0000000000..2e7628639a --- /dev/null +++ b/test/TypeGuards.ts @@ -0,0 +1,115 @@ +import { expect } from "./utils"; +import { parseJson } from "../src/utils"; + +describe("parseJson", function () { + describe("stringArray", function () { + it("parses a valid string array", function () { + expect(parseJson.stringArray('["a", "b", "c"]')).to.deep.equal(["a", "b", "c"]); + }); + + it("returns empty array for default input", function () { + expect(parseJson.stringArray()).to.deep.equal([]); + }); + + it("throws on number array", function () { + expect(() => parseJson.stringArray("[1, 2]")).to.throw(); + }); + + it("throws on object input", function () { + expect(() => parseJson.stringArray('{"a": 1}')).to.throw(); + }); + + it("throws on mixed array", function () { + expect(() => parseJson.stringArray('["a", 1]')).to.throw(); + }); + }); + + describe("numberArray", function () { + it("parses a valid number array", function () { + expect(parseJson.numberArray("[1, 2, 3]")).to.deep.equal([1, 2, 3]); + }); + + it("returns empty array for default input", function () { + expect(parseJson.numberArray()).to.deep.equal([]); + }); + + it("throws on string array", function () { + expect(() => parseJson.numberArray('["a"]')).to.throw(); + }); + }); + + describe("stringMap", function () { + it("parses a valid string map", function () { + expect(parseJson.stringMap('{"a": "1", "b": "2"}')).to.deep.equal({ a: "1", b: "2" }); + }); + + it("returns empty object for default input", function () { + expect(parseJson.stringMap()).to.deep.equal({}); + }); + + it("throws on numeric values", function () { + expect(() => parseJson.stringMap('{"a": 1}')).to.throw(); + }); + + it("throws on non-string values in array input", function () { + expect(() => parseJson.stringMap("[1]")).to.throw(); + }); + }); + + describe("numberMap", function () { + it("parses a valid number map", function () { + expect(parseJson.numberMap('{"a": 1, "b": 2}')).to.deep.equal({ a: 1, b: 2 }); + }); + + it("returns empty object for default input", function () { + expect(parseJson.numberMap()).to.deep.equal({}); + }); + + it("throws on string values", function () { + expect(() => parseJson.numberMap('{"a": "1"}')).to.throw(); + }); + }); + + describe("numericMap", function () { + it("parses string values", function () { + expect(parseJson.numericMap('{"a": "5"}')).to.deep.equal({ a: "5" }); + }); + + it("parses number values", function () { + expect(parseJson.numericMap('{"a": 5}')).to.deep.equal({ a: 5 }); + }); + + it("parses mixed string and number values", function () { + expect(parseJson.numericMap('{"a": "5", "b": 10}')).to.deep.equal({ a: "5", b: 10 }); + }); + + it("returns empty object for default input", function () { + expect(parseJson.numericMap()).to.deep.equal({}); + }); + + it("throws on boolean values", function () { + expect(() => parseJson.numericMap('{"a": true}')).to.throw(); + }); + }); + + describe("stringArrayMap", function () { + it("parses a valid string array map", function () { + expect(parseJson.stringArrayMap('{"a": ["x", "y"], "b": ["z"]}')).to.deep.equal({ + a: ["x", "y"], + b: ["z"], + }); + }); + + it("returns empty object for default input", function () { + expect(parseJson.stringArrayMap()).to.deep.equal({}); + }); + + it("throws on non-array values", function () { + expect(() => parseJson.stringArrayMap('{"a": "x"}')).to.throw(); + }); + + it("throws on number array values", function () { + expect(() => parseJson.stringArrayMap('{"a": [1, 2]}')).to.throw(); + }); + }); +});