From 7804647facecf662b459528f37a01f92d8f4bbb7 Mon Sep 17 00:00:00 2001 From: joshpainter Date: Wed, 11 Feb 2026 01:43:10 -0600 Subject: [PATCH] Add comprehensive test coverage across frontend and Rust crates Implements test infrastructure and ~268 new tests covering areas that previously had zero test coverage. Frontend uses Vitest with jsdom and Testing Library; Rust tests use in-memory SQLite and standard #[test]/#[tokio::test] patterns. Frontend (170 tests): - Vitest setup with Tauri IPC mocking (invoke, events, plugins) - Pure function tests: amount conversion, addresses, hex, formatting, URLs - WalletConnect schema validation for all 17 commands - Zustand store tests with mocked bindings - Handler tests for CHIP-0002, offers, and high-level commands Rust (98 tests): - sage-keychain: encrypt/decrypt round-trips, keychain CRUD, serialization - sage-database: blocks, offers, collections, mempool, type conversion utils - sage-config: config defaults/round-trip, network inheritance, v1 migration - sage parse: all parse_* functions (asset IDs, coins, hashes, signatures) CI: adds lint and frontend test steps to build workflow. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 6 + Cargo.lock | 3 + crates/sage-config/src/config.rs | 68 ++ crates/sage-config/src/network.rs | 119 +++ crates/sage-config/src/old.rs | 117 +++ crates/sage-database/Cargo.toml | 5 + crates/sage-database/src/lib.rs | 2 + crates/sage-database/src/tables/blocks.rs | 80 ++ .../sage-database/src/tables/collections.rs | 79 ++ .../sage-database/src/tables/mempool_items.rs | 101 +++ crates/sage-database/src/tables/offers.rs | 96 +++ crates/sage-database/src/test_utils.rs | 16 + crates/sage-database/src/utils.rs | 96 +++ crates/sage-keychain/Cargo.toml | 3 + crates/sage-keychain/src/encrypt.rs | 93 +++ crates/sage-keychain/src/keychain.rs | 162 ++++ crates/sage/src/utils/parse.rs | 181 ++++- package.json | 12 +- pnpm-lock.yaml | 753 ++++++++++++++++++ src/__tests__/helpers/renderWithProviders.tsx | 24 + src/__tests__/setup.ts | 89 +++ src/lib/utils.test.ts | 272 +++++++ src/state.test.ts | 167 ++++ src/validation.test.ts | 42 + src/walletconnect/commands.test.ts | 467 +++++++++++ src/walletconnect/commands/chip0002.test.ts | 266 +++++++ src/walletconnect/commands/high-level.test.ts | 282 +++++++ src/walletconnect/commands/offers.test.ts | 147 ++++ vitest.config.ts | 24 + 29 files changed, 3759 insertions(+), 13 deletions(-) create mode 100644 crates/sage-database/src/test_utils.rs create mode 100644 src/__tests__/helpers/renderWithProviders.tsx create mode 100644 src/__tests__/setup.ts create mode 100644 src/lib/utils.test.ts create mode 100644 src/state.test.ts create mode 100644 src/validation.test.ts create mode 100644 src/walletconnect/commands.test.ts create mode 100644 src/walletconnect/commands/chip0002.test.ts create mode 100644 src/walletconnect/commands/high-level.test.ts create mode 100644 src/walletconnect/commands/offers.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf1eda352..c0e311329 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,12 @@ jobs: - name: Prettier run: pnpm prettier:check + - name: Lint + run: pnpm lint + + - name: Frontend Tests + run: pnpm test + - name: Install GTK run: sudo apt-get update && sudo apt-get install libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev diff --git a/Cargo.lock b/Cargo.lock index 3f8c13e78..2669898fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6333,10 +6333,12 @@ dependencies = [ name = "sage-database" version = "0.12.8" dependencies = [ + "anyhow", "chia-wallet-sdk", "hex", "sqlx", "thiserror 1.0.69", + "tokio", "tracing", ] @@ -6345,6 +6347,7 @@ name = "sage-keychain" version = "0.12.8" dependencies = [ "aes-gcm", + "anyhow", "argon2", "bincode 1.3.3", "bip39", diff --git a/crates/sage-config/src/config.rs b/crates/sage-config/src/config.rs index 4c7a8a0c1..ac20b2d21 100644 --- a/crates/sage-config/src/config.rs +++ b/crates/sage-config/src/config.rs @@ -70,3 +70,71 @@ impl Default for RpcConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_defaults() { + let config = Config::default(); + assert_eq!(config.version, 2); + assert_eq!(config.global.log_level, "INFO"); + assert!(config.global.fingerprint.is_none()); + assert_eq!(config.network.default_network, "mainnet"); + assert_eq!(config.network.target_peers, 5); + assert!(config.network.discover_peers); + assert!(!config.rpc.enabled); + assert_eq!(config.rpc.port, 9257); + } + + #[test] + fn config_toml_round_trip() { + let config = Config::default(); + let toml_str = toml::to_string(&config).unwrap(); + let parsed: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!(config, parsed); + } + + #[test] + fn config_partial_deserialize() { + // Only specify a few fields, rest should use defaults + let toml_str = r#" +version = 2 + +[global] +log_level = "DEBUG" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.global.log_level, "DEBUG"); + assert_eq!(config.network.default_network, "mainnet"); // default + assert!(!config.rpc.enabled); // default + } + + #[test] + fn config_with_fingerprint() { + let toml_str = r#" +version = 2 + +[global] +log_level = "INFO" +fingerprint = 12345 + +[network] +default_network = "testnet11" +target_peers = 3 +discover_peers = false + +[rpc] +enabled = true +port = 8080 +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.global.fingerprint, Some(12345)); + assert_eq!(config.network.default_network, "testnet11"); + assert_eq!(config.network.target_peers, 3); + assert!(!config.network.discover_peers); + assert!(config.rpc.enabled); + assert_eq!(config.rpc.port, 8080); + } +} diff --git a/crates/sage-config/src/network.rs b/crates/sage-config/src/network.rs index e3bead419..4aa8cc54d 100644 --- a/crates/sage-config/src/network.rs +++ b/crates/sage-config/src/network.rs @@ -190,3 +190,122 @@ pub static TESTNET11: LazyLock = LazyLock::new(|| Network { additional_peer_introducers: vec!["introducer-testnet11.chia.net".to_string()], inherit: Some(InheritedNetwork::Testnet11), }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mainnet_defaults() { + assert_eq!(MAINNET.name, "mainnet"); + assert_eq!(MAINNET.ticker, "XCH"); + assert_eq!(MAINNET.default_port, 8444); + assert_eq!(MAINNET.precision, 12); + assert!(MAINNET.prefix.is_none()); + assert!(MAINNET.network_id.is_none()); + assert!(MAINNET.agg_sig_me.is_none()); + } + + #[test] + fn testnet11_defaults() { + assert_eq!(TESTNET11.name, "testnet11"); + assert_eq!(TESTNET11.ticker, "TXCH"); + assert_eq!(TESTNET11.default_port, 58444); + assert_eq!(TESTNET11.precision, 12); + } + + #[test] + fn prefix_fallback_to_lowercase_ticker() { + assert_eq!(MAINNET.prefix(), "xch"); + assert_eq!(TESTNET11.prefix(), "txch"); + } + + #[test] + fn prefix_custom_override() { + let mut network = MAINNET.clone(); + network.prefix = Some("custom".to_string()); + assert_eq!(network.prefix(), "custom"); + } + + #[test] + fn network_id_fallback_to_name() { + assert_eq!(MAINNET.network_id(), "mainnet"); + assert_eq!(TESTNET11.network_id(), "testnet11"); + } + + #[test] + fn network_id_custom_override() { + let mut network = MAINNET.clone(); + network.network_id = Some("custom-id".to_string()); + assert_eq!(network.network_id(), "custom-id"); + } + + #[test] + fn agg_sig_me_fallback_to_genesis_challenge() { + assert_eq!(MAINNET.agg_sig_me(), MAINNET.genesis_challenge); + } + + #[test] + fn agg_sig_me_custom_override() { + let custom = Bytes32::new([42; 32]); + let mut network = MAINNET.clone(); + network.agg_sig_me = Some(custom); + assert_eq!(network.agg_sig_me(), custom); + } + + #[test] + fn by_name_finds_mainnet() { + let list = NetworkList::default(); + let found = list.by_name("mainnet"); + assert!(found.is_some()); + assert_eq!(found.unwrap().ticker, "XCH"); + } + + #[test] + fn by_name_finds_testnet11() { + let list = NetworkList::default(); + let found = list.by_name("testnet11"); + assert!(found.is_some()); + assert_eq!(found.unwrap().ticker, "TXCH"); + } + + #[test] + fn by_name_returns_none_for_unknown() { + let list = NetworkList::default(); + assert!(list.by_name("unknown_network").is_none()); + } + + #[test] + fn dns_introducers_inherited_from_mainnet() { + let network = Network { + additional_dns_introducers: Vec::new(), + ..MAINNET.clone() + }; + let introducers = network.dns_introducers(); + assert!(!introducers.is_empty()); + assert!(introducers.contains(&"dns-introducer.chia.net".to_string())); + } + + #[test] + fn dns_introducers_no_inheritance() { + let mut network = MAINNET.clone(); + network.inherit = None; + network.additional_dns_introducers = vec!["custom.dns".to_string()]; + let introducers = network.dns_introducers(); + assert_eq!(introducers, vec!["custom.dns".to_string()]); + } + + #[test] + fn dns_introducers_merged_without_duplicates() { + let network = Network { + additional_dns_introducers: vec!["dns-introducer.chia.net".to_string()], + ..MAINNET.clone() + }; + let introducers = network.dns_introducers(); + let count = introducers + .iter() + .filter(|i| *i == "dns-introducer.chia.net") + .count(); + assert_eq!(count, 1, "Should not have duplicate introducers"); + } +} diff --git a/crates/sage-config/src/old.rs b/crates/sage-config/src/old.rs index 98fb7bc8f..71165859e 100644 --- a/crates/sage-config/src/old.rs +++ b/crates/sage-config/src/old.rs @@ -198,3 +198,120 @@ pub fn migrate_networks(old: IndexMap) -> NetworkList { .collect(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn old_config_default_is_v1() { + let old = OldConfig::default(); + assert!(old.is_old()); + } + + #[test] + fn migrate_config_basic() { + let old = OldConfig::default(); + let (config, wallet_config) = migrate_config(old).unwrap(); + + assert_eq!(config.version, 2); + assert_eq!(config.global.log_level, "INFO"); + assert!(config.global.fingerprint.is_none()); + assert_eq!(config.network.default_network, "mainnet"); + assert_eq!(config.network.target_peers, 5); + assert!(config.network.discover_peers); + assert!(!config.rpc.enabled); + assert_eq!(config.rpc.port, 9257); + assert!(wallet_config.wallets.is_empty()); + } + + #[test] + fn migrate_config_with_fingerprint_and_wallets() { + let mut old = OldConfig::default(); + old.app.active_fingerprint = Some(12345); + old.app.log_level = "DEBUG".to_string(); + old.rpc.run_on_startup = true; + old.rpc.server_port = 8080; + old.network.network_id = "testnet11".to_string(); + old.wallets.insert( + "67890".to_string(), + OldWalletConfig { + name: "My Wallet".to_string(), + ..OldWalletConfig::default() + }, + ); + + let (config, wallet_config) = migrate_config(old).unwrap(); + + assert_eq!(config.global.fingerprint, Some(12345)); + assert_eq!(config.global.log_level, "DEBUG"); + assert!(config.rpc.enabled); + assert_eq!(config.rpc.port, 8080); + assert_eq!(config.network.default_network, "testnet11"); + assert_eq!(wallet_config.wallets.len(), 1); + assert_eq!(wallet_config.wallets[0].fingerprint, 67890); + assert_eq!(wallet_config.wallets[0].name, "My Wallet"); + } + + #[test] + fn migrate_config_invalid_fingerprint_key() { + let mut old = OldConfig::default(); + old.wallets.insert( + "not_a_number".to_string(), + OldWalletConfig::default(), + ); + let result = migrate_config(old); + assert!(result.is_err()); + } + + #[test] + fn migrate_networks_mainnet_inherits() { + let genesis = Bytes32::new([1; 32]); + let mut networks = IndexMap::new(); + networks.insert( + "mainnet".to_string(), + OldNetwork { + default_port: 8444, + ticker: "XCH".to_string(), + address_prefix: "xch".to_string(), + precision: 12, + genesis_challenge: genesis, + agg_sig_me: genesis, // same as genesis → should become None + dns_introducers: vec!["dns.example.com".to_string()], + }, + ); + + let result = migrate_networks(networks); + assert_eq!(result.networks.len(), 1); + let net = &result.networks[0]; + assert_eq!(net.name, "mainnet"); + assert!(net.prefix.is_none()); // matches lowercase ticker + assert!(net.agg_sig_me.is_none()); // matches genesis + assert!(matches!(net.inherit, Some(InheritedNetwork::Mainnet))); + } + + #[test] + fn migrate_networks_custom_prefix_preserved() { + let genesis = Bytes32::new([2; 32]); + let agg = Bytes32::new([3; 32]); + let mut networks = IndexMap::new(); + networks.insert( + "custom".to_string(), + OldNetwork { + default_port: 9999, + ticker: "CUST".to_string(), + address_prefix: "mycustom".to_string(), // doesn't match "cust" + precision: 6, + genesis_challenge: genesis, + agg_sig_me: agg, // different from genesis + dns_introducers: vec![], + }, + ); + + let result = migrate_networks(networks); + let net = &result.networks[0]; + assert_eq!(net.prefix, Some("mycustom".to_string())); + assert_eq!(net.agg_sig_me, Some(agg)); + assert!(net.inherit.is_none()); // not mainnet or testnet11 + } +} diff --git a/crates/sage-database/Cargo.toml b/crates/sage-database/Cargo.toml index 1732623b5..8692cbaf7 100644 --- a/crates/sage-database/Cargo.toml +++ b/crates/sage-database/Cargo.toml @@ -20,3 +20,8 @@ sqlx = { workspace = true, features = ["sqlite"] } thiserror = { workspace = true } tracing = { workspace = true } hex = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +sqlx = { workspace = true, features = ["runtime-tokio", "sqlite"] } +tokio = { workspace = true } diff --git a/crates/sage-database/src/lib.rs b/crates/sage-database/src/lib.rs index 51a65c543..93c73951a 100644 --- a/crates/sage-database/src/lib.rs +++ b/crates/sage-database/src/lib.rs @@ -1,6 +1,8 @@ mod maintenance; mod serialized_primitives; mod tables; +#[cfg(test)] +pub(crate) mod test_utils; mod utils; pub use maintenance::*; diff --git a/crates/sage-database/src/tables/blocks.rs b/crates/sage-database/src/tables/blocks.rs index ad19c53fb..8c336a242 100644 --- a/crates/sage-database/src/tables/blocks.rs +++ b/crates/sage-database/src/tables/blocks.rs @@ -107,3 +107,83 @@ async fn latest_peak(conn: impl SqliteExecutor<'_>) -> Result Bytes32 { + Bytes32::new([byte; 32]) + } + + #[tokio::test] + async fn empty_peak_returns_none() -> anyhow::Result<()> { + let db = test_database().await?; + let peak = db.latest_peak().await?; + assert!(peak.is_none()); + Ok(()) + } + + #[tokio::test] + async fn insert_block_and_get_peak() -> anyhow::Result<()> { + let db = test_database().await?; + let hash = test_hash(1); + + db.insert_block(100, hash, Some(1000), true).await?; + + let peak = db.latest_peak().await?; + assert!(peak.is_some()); + let (height, peak_hash) = peak.unwrap(); + assert_eq!(height, 100); + assert_eq!(peak_hash, hash); + Ok(()) + } + + #[tokio::test] + async fn upsert_block_updates_existing() -> anyhow::Result<()> { + let db = test_database().await?; + let hash1 = test_hash(1); + let hash2 = test_hash(2); + + // Insert without timestamp + db.insert_block(50, hash1, None, false).await?; + + // Upsert with timestamp and different hash + db.insert_block(50, hash2, Some(500), true).await?; + + let peak = db.latest_peak().await?; + assert!(peak.is_some()); + let (height, peak_hash) = peak.unwrap(); + assert_eq!(height, 50); + assert_eq!(peak_hash, hash2); + Ok(()) + } + + #[tokio::test] + async fn latest_peak_picks_highest() -> anyhow::Result<()> { + let db = test_database().await?; + + db.insert_block(10, test_hash(1), Some(100), true).await?; + db.insert_block(20, test_hash(2), Some(200), true).await?; + db.insert_block(15, test_hash(3), Some(150), true).await?; + + let (height, hash) = db.latest_peak().await?.unwrap(); + assert_eq!(height, 20); + assert_eq!(hash, test_hash(2)); + Ok(()) + } + + #[tokio::test] + async fn insert_height_via_tx() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + tx.insert_height(42).await?; + tx.commit().await?; + + // Height inserted but no peak (no header_hash) + let peak = db.latest_peak().await?; + assert!(peak.is_none()); + Ok(()) + } +} diff --git a/crates/sage-database/src/tables/collections.rs b/crates/sage-database/src/tables/collections.rs index 815c79f3e..1af930393 100644 --- a/crates/sage-database/src/tables/collections.rs +++ b/crates/sage-database/src/tables/collections.rs @@ -158,3 +158,82 @@ async fn set_collection_visible( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_database; + + fn test_hash(byte: u8) -> Bytes32 { + Bytes32::new([byte; 32]) + } + + fn test_collection(byte: u8) -> CollectionRow { + CollectionRow { + hash: test_hash(byte), + uuid: format!("uuid-{byte}"), + minter_hash: test_hash(byte + 100), + name: Some(format!("Collection {byte}")), + icon_url: None, + banner_url: None, + description: Some(format!("Desc {byte}")), + is_visible: true, + } + } + + #[tokio::test] + async fn insert_and_retrieve_collection() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + let col = test_collection(1); + tx.insert_collection(col).await?; + tx.commit().await?; + + let fetched = db.collection(test_hash(1)).await?; + assert!(fetched.is_some()); + let fetched = fetched.unwrap(); + assert_eq!(fetched.name.as_deref(), Some("Collection 1")); + assert_eq!(fetched.uuid, "uuid-1"); + assert!(fetched.is_visible); + Ok(()) + } + + #[tokio::test] + async fn nonexistent_collection_returns_none() -> anyhow::Result<()> { + let db = test_database().await?; + let fetched = db.collection(test_hash(99)).await?; + assert!(fetched.is_none()); + Ok(()) + } + + #[tokio::test] + async fn set_collection_visibility() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + tx.insert_collection(test_collection(1)).await?; + tx.commit().await?; + + db.set_collection_visible(test_hash(1), false).await?; + + let fetched = db.collection(test_hash(1)).await?.unwrap(); + assert!(!fetched.is_visible); + + db.set_collection_visible(test_hash(1), true).await?; + + let fetched = db.collection(test_hash(1)).await?.unwrap(); + assert!(fetched.is_visible); + Ok(()) + } + + #[tokio::test] + async fn default_collection_exists_after_migration() -> anyhow::Result<()> { + let db = test_database().await?; + + // The default collection (id=0) is inserted by migrations + // Verify the database was set up correctly + let (collections, _count) = db.collections(100, 0, true).await?; + // Even if empty, this shouldn't error + assert!(collections.len() <= 1); // May or may not have NFTs + Ok(()) + } +} diff --git a/crates/sage-database/src/tables/mempool_items.rs b/crates/sage-database/src/tables/mempool_items.rs index 6c5541990..0521d73e7 100644 --- a/crates/sage-database/src/tables/mempool_items.rs +++ b/crates/sage-database/src/tables/mempool_items.rs @@ -330,3 +330,104 @@ async fn mempool_items(conn: impl SqliteExecutor<'_>) -> Result }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_database; + + fn test_hash(byte: u8) -> Bytes32 { + Bytes32::new([byte; 32]) + } + + fn test_sig() -> Signature { + Signature::default() + } + + #[tokio::test] + async fn empty_mempool() -> anyhow::Result<()> { + let db = test_database().await?; + let items = db.mempool_items().await?; + assert!(items.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn insert_and_list_mempool_items() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + + tx.insert_mempool_item(test_hash(1), test_sig(), 100) + .await?; + tx.insert_mempool_item(test_hash(2), test_sig(), 200) + .await?; + tx.commit().await?; + + let items = db.mempool_items().await?; + assert_eq!(items.len(), 2); + + let fees: Vec = items.iter().map(|i| i.fee).collect(); + assert!(fees.contains(&100)); + assert!(fees.contains(&200)); + Ok(()) + } + + #[tokio::test] + async fn remove_mempool_item() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + tx.insert_mempool_item(test_hash(1), test_sig(), 100) + .await?; + tx.commit().await?; + + let mut tx = db.tx().await?; + tx.remove_mempool_item(test_hash(1)).await?; + tx.commit().await?; + + let items = db.mempool_items().await?; + assert!(items.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn update_mempool_item_time() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + tx.insert_mempool_item(test_hash(1), test_sig(), 50) + .await?; + tx.commit().await?; + + // Initially no submitted_timestamp + let items = db.mempool_items().await?; + assert!(items[0].submitted_timestamp.is_none()); + + // Update timestamp + db.update_mempool_item_time(test_hash(1)).await?; + + let items = db.mempool_items().await?; + assert!(items[0].submitted_timestamp.is_some()); + Ok(()) + } + + #[tokio::test] + async fn mempool_items_to_submit_returns_unsubmitted() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + tx.insert_mempool_item(test_hash(1), test_sig(), 100) + .await?; + tx.insert_mempool_item(test_hash(2), test_sig(), 200) + .await?; + tx.commit().await?; + + // Both should appear (no submitted_timestamp) + let to_submit = db.mempool_items_to_submit(60, 10).await?; + assert_eq!(to_submit.len(), 2); + + // After updating one, it shouldn't appear (within check window) + db.update_mempool_item_time(test_hash(1)).await?; + let to_submit = db.mempool_items_to_submit(9999, 10).await?; + assert_eq!(to_submit.len(), 1); + assert_eq!(to_submit[0].hash, test_hash(2)); + Ok(()) + } +} diff --git a/crates/sage-database/src/tables/offers.rs b/crates/sage-database/src/tables/offers.rs index 0cb040b83..6f1443148 100644 --- a/crates/sage-database/src/tables/offers.rs +++ b/crates/sage-database/src/tables/offers.rs @@ -398,3 +398,99 @@ async fn update_offer_status( .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_database; + + fn test_hash(byte: u8) -> Bytes32 { + Bytes32::new([byte; 32]) + } + + fn test_offer(byte: u8, status: OfferStatus) -> OfferRow { + OfferRow { + offer_id: test_hash(byte), + encoded_offer: format!("offer_{byte}"), + expiration_height: None, + expiration_timestamp: None, + fee: 100, + status, + inserted_timestamp: 1000 + byte as u64, + } + } + + #[tokio::test] + async fn insert_and_fetch_offer() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + let offer = test_offer(1, OfferStatus::Active); + tx.insert_offer(offer.clone()).await?; + tx.commit().await?; + + let fetched = db.offer(test_hash(1)).await?; + assert!(fetched.is_some()); + let fetched = fetched.unwrap(); + assert_eq!(fetched.encoded_offer, "offer_1"); + assert_eq!(fetched.fee, 100); + assert!(matches!(fetched.status, OfferStatus::Active)); + Ok(()) + } + + #[tokio::test] + async fn fetch_nonexistent_offer_returns_none() -> anyhow::Result<()> { + let db = test_database().await?; + let fetched = db.offer(test_hash(99)).await?; + assert!(fetched.is_none()); + Ok(()) + } + + #[tokio::test] + async fn list_offers_by_status() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + tx.insert_offer(test_offer(1, OfferStatus::Active)).await?; + tx.insert_offer(test_offer(2, OfferStatus::Pending)).await?; + tx.insert_offer(test_offer(3, OfferStatus::Active)).await?; + tx.commit().await?; + + let active = db.offers(Some(OfferStatus::Active)).await?; + assert_eq!(active.len(), 2); + + let pending = db.offers(Some(OfferStatus::Pending)).await?; + assert_eq!(pending.len(), 1); + + let all = db.offers(None).await?; + assert_eq!(all.len(), 3); + Ok(()) + } + + #[tokio::test] + async fn update_offer_status_works() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + tx.insert_offer(test_offer(1, OfferStatus::Active)).await?; + tx.commit().await?; + + db.update_offer_status(test_hash(1), OfferStatus::Completed) + .await?; + + let fetched = db.offer(test_hash(1)).await?.unwrap(); + assert!(matches!(fetched.status, OfferStatus::Completed)); + Ok(()) + } + + #[tokio::test] + async fn delete_offer_works() -> anyhow::Result<()> { + let db = test_database().await?; + let mut tx = db.tx().await?; + tx.insert_offer(test_offer(1, OfferStatus::Active)).await?; + tx.commit().await?; + + db.delete_offer(test_hash(1)).await?; + + let fetched = db.offer(test_hash(1)).await?; + assert!(fetched.is_none()); + Ok(()) + } +} diff --git a/crates/sage-database/src/test_utils.rs b/crates/sage-database/src/test_utils.rs new file mode 100644 index 000000000..8e6cdb466 --- /dev/null +++ b/crates/sage-database/src/test_utils.rs @@ -0,0 +1,16 @@ +use sqlx::{SqlitePool, migrate}; +use std::sync::atomic::{AtomicU32, Ordering}; + +use crate::Database; + +static DB_INDEX: AtomicU32 = AtomicU32::new(0); + +/// Create an in-memory SQLite database with all migrations applied. +pub async fn test_database() -> anyhow::Result { + let index = DB_INDEX.fetch_add(1, Ordering::SeqCst); + let pool = + SqlitePool::connect(&format!("file:testdb_sage_database_{index}?mode=memory&cache=shared")) + .await?; + migrate!("../../migrations").run(&pool).await?; + Ok(Database::new(pool)) +} diff --git a/crates/sage-database/src/utils.rs b/crates/sage-database/src/utils.rs index 5f875c64f..d4cd911df 100644 --- a/crates/sage-database/src/utils.rs +++ b/crates/sage-database/src/utils.rs @@ -76,3 +76,99 @@ where self.map(U::convert).transpose() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vec_to_fixed_array() { + let v: Vec = vec![1, 2, 3, 4]; + let result: [u8; 4] = v.convert().unwrap(); + assert_eq!(result, [1, 2, 3, 4]); + } + + #[test] + fn vec_to_fixed_array_wrong_length() { + let v: Vec = vec![1, 2, 3]; + let result: std::result::Result<[u8; 4], _> = v.convert(); + assert!(result.is_err()); + } + + #[test] + fn i64_to_u32() { + let v: i64 = 42; + let result: u32 = v.convert().unwrap(); + assert_eq!(result, 42); + } + + #[test] + fn i64_to_u32_max() { + let v: i64 = u32::MAX as i64; + let result: u32 = v.convert().unwrap(); + assert_eq!(result, u32::MAX); + } + + #[test] + fn negative_i64_to_u32_fails() { + let v: i64 = -1; + let result: std::result::Result = v.convert(); + assert!(result.is_err()); + } + + #[test] + fn i64_to_u64() { + let v: i64 = 1_000_000; + let result: u64 = v.convert().unwrap(); + assert_eq!(result, 1_000_000); + } + + #[test] + fn i64_to_u8() { + let v: i64 = 255; + let result: u8 = v.convert().unwrap(); + assert_eq!(result, 255); + } + + #[test] + fn i64_to_u8_overflow() { + let v: i64 = 256; + let result: std::result::Result = v.convert(); + assert!(result.is_err()); + } + + #[test] + fn i64_to_u16() { + let v: i64 = 65535; + let result: u16 = v.convert().unwrap(); + assert_eq!(result, 65535); + } + + #[test] + fn option_some_converts() { + let v: Option = Some(42); + let result: Option = v.convert().unwrap(); + assert_eq!(result, Some(42)); + } + + #[test] + fn option_none_converts() { + let v: Option = None; + let result: Option = v.convert().unwrap(); + assert_eq!(result, None); + } + + #[test] + fn vec_to_u64_via_be_bytes() { + let v: Vec = 1000u64.to_be_bytes().to_vec(); + let result: u64 = v.convert().unwrap(); + assert_eq!(result, 1000); + } + + #[test] + fn vec_to_u128_via_be_bytes() { + let v: Vec = 999u128.to_be_bytes().to_vec(); + let result: u128 = v.convert().unwrap(); + assert_eq!(result, 999); + } +} diff --git a/crates/sage-keychain/Cargo.toml b/crates/sage-keychain/Cargo.toml index 0f8c14b51..ebec4a873 100644 --- a/crates/sage-keychain/Cargo.toml +++ b/crates/sage-keychain/Cargo.toml @@ -25,3 +25,6 @@ aes-gcm = { workspace = true } rand = { workspace = true } rand_chacha = { workspace = true } argon2 = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } diff --git a/crates/sage-keychain/src/encrypt.rs b/crates/sage-keychain/src/encrypt.rs index 1b337e81f..8d8f6f4f5 100644 --- a/crates/sage-keychain/src/encrypt.rs +++ b/crates/sage-keychain/src/encrypt.rs @@ -62,3 +62,96 @@ where Ok(bincode::deserialize(&data)?) } + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + + fn seeded_rng(seed: u64) -> ChaCha20Rng { + ChaCha20Rng::seed_from_u64(seed) + } + + #[test] + fn encrypt_decrypt_round_trip() { + let password = b"test-password"; + let mut rng = seeded_rng(42); + let data: Vec = vec![1, 2, 3, 4, 5]; + + let encrypted = encrypt(password, &mut rng, &data).unwrap(); + let decrypted: Vec = decrypt(&encrypted, password).unwrap(); + + assert_eq!(data, decrypted); + } + + #[test] + fn wrong_password_fails() { + let mut rng = seeded_rng(42); + let data: Vec = vec![10, 20, 30]; + + let encrypted = encrypt(b"correct", &mut rng, &data).unwrap(); + let result = decrypt::>(&encrypted, b"wrong"); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), KeychainError::Decrypt)); + } + + #[test] + fn different_seeds_produce_different_salts() { + let data: Vec = vec![1, 2, 3]; + + let mut rng1 = seeded_rng(1); + let mut rng2 = seeded_rng(2); + + let enc1 = encrypt(b"pass", &mut rng1, &data).unwrap(); + let enc2 = encrypt(b"pass", &mut rng2, &data).unwrap(); + + assert_ne!(enc1.salt, enc2.salt); + } + + #[test] + fn empty_data_round_trip() { + let mut rng = seeded_rng(99); + let data: Vec = vec![]; + + let encrypted = encrypt(b"pass", &mut rng, &data).unwrap(); + let decrypted: Vec = decrypt(&encrypted, b"pass").unwrap(); + + assert_eq!(data, decrypted); + } + + #[test] + fn tampered_ciphertext_fails() { + let mut rng = seeded_rng(42); + let data: Vec = vec![1, 2, 3]; + + let mut encrypted = encrypt(b"pass", &mut rng, &data).unwrap(); + + // Tamper with ciphertext + if let Some(byte) = encrypted.ciphertext.first_mut() { + *byte ^= 0xFF; + } + + let result = decrypt::>(&encrypted, b"pass"); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), KeychainError::Decrypt)); + } + + #[test] + fn tampered_nonce_fails() { + let mut rng = seeded_rng(42); + let data: Vec = vec![1, 2, 3]; + + let mut encrypted = encrypt(b"pass", &mut rng, &data).unwrap(); + + // Tamper with nonce + if let Some(byte) = encrypted.nonce.first_mut() { + *byte ^= 0xFF; + } + + let result = decrypt::>(&encrypted, b"pass"); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), KeychainError::Decrypt)); + } +} diff --git a/crates/sage-keychain/src/keychain.rs b/crates/sage-keychain/src/keychain.rs index 235f143a8..0ec727ef4 100644 --- a/crates/sage-keychain/src/keychain.rs +++ b/crates/sage-keychain/src/keychain.rs @@ -176,3 +176,165 @@ impl Keychain { Ok(fingerprint) } } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const PASSWORD: &[u8] = b"test-password"; + + fn test_mnemonic() -> Mnemonic { + Mnemonic::parse(TEST_MNEMONIC).unwrap() + } + + #[test] + fn add_mnemonic_and_extract_round_trip() { + let mut keychain = Keychain::default(); + let mnemonic = test_mnemonic(); + + let fingerprint = keychain.add_mnemonic(&mnemonic, PASSWORD).unwrap(); + assert!(keychain.contains(fingerprint)); + assert!(keychain.has_secret_key(fingerprint)); + + let (extracted_mnemonic, extracted_sk) = + keychain.extract_secrets(fingerprint, PASSWORD).unwrap(); + + assert_eq!(extracted_mnemonic.unwrap().to_string(), mnemonic.to_string()); + assert!(extracted_sk.is_some()); + } + + #[test] + fn add_secret_key_extract_has_no_mnemonic() { + let mut keychain = Keychain::default(); + let mnemonic = test_mnemonic(); + let sk = SecretKey::from_seed(&mnemonic.to_seed("")); + + let fingerprint = keychain.add_secret_key(&sk, PASSWORD).unwrap(); + assert!(keychain.has_secret_key(fingerprint)); + + let (extracted_mnemonic, extracted_sk) = + keychain.extract_secrets(fingerprint, PASSWORD).unwrap(); + + // Mnemonic should be None when added as raw secret key + assert!(extracted_mnemonic.is_none()); + assert!(extracted_sk.is_some()); + } + + #[test] + fn add_public_key_has_no_secret() { + let mut keychain = Keychain::default(); + let mnemonic = test_mnemonic(); + let sk = SecretKey::from_seed(&mnemonic.to_seed("")); + let pk = sk.public_key(); + + let fingerprint = keychain.add_public_key(&pk).unwrap(); + assert!(keychain.contains(fingerprint)); + assert!(!keychain.has_secret_key(fingerprint)); + + let (mnemonic_out, sk_out) = + keychain.extract_secrets(fingerprint, PASSWORD).unwrap(); + assert!(mnemonic_out.is_none()); + assert!(sk_out.is_none()); + } + + #[test] + fn duplicate_key_returns_error() { + let mut keychain = Keychain::default(); + let mnemonic = test_mnemonic(); + + keychain.add_mnemonic(&mnemonic, PASSWORD).unwrap(); + let result = keychain.add_mnemonic(&mnemonic, PASSWORD); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), KeychainError::KeyExists)); + } + + #[test] + fn remove_key() { + let mut keychain = Keychain::default(); + let mnemonic = test_mnemonic(); + let fingerprint = keychain.add_mnemonic(&mnemonic, PASSWORD).unwrap(); + + assert!(keychain.contains(fingerprint)); + assert!(keychain.remove(fingerprint)); + assert!(!keychain.contains(fingerprint)); + assert!(!keychain.remove(fingerprint)); // Already removed + } + + #[test] + fn wrong_password_fails_extract() { + let mut keychain = Keychain::default(); + let mnemonic = test_mnemonic(); + let fingerprint = keychain.add_mnemonic(&mnemonic, PASSWORD).unwrap(); + + let result = keychain.extract_secrets(fingerprint, b"wrong-password"); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), KeychainError::Decrypt)); + } + + #[test] + fn fingerprints_iteration() { + let mut keychain = Keychain::default(); + + // Add 3 different keys using different mnemonics + let m1 = Mnemonic::parse("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").unwrap(); + let m2 = Mnemonic::parse("zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong").unwrap(); + let sk3 = SecretKey::from_seed(&[42u8; 64]); + let pk3 = sk3.public_key(); + + let f1 = keychain.add_mnemonic(&m1, PASSWORD).unwrap(); + let f2 = keychain.add_mnemonic(&m2, PASSWORD).unwrap(); + let f3 = keychain.add_public_key(&pk3).unwrap(); + + let mut fps: Vec = keychain.fingerprints().collect(); + fps.sort(); + + let mut expected = vec![f1, f2, f3]; + expected.sort(); + + assert_eq!(fps, expected); + assert_eq!(fps.len(), 3); + } + + #[test] + fn extract_public_key() { + let mut keychain = Keychain::default(); + let mnemonic = test_mnemonic(); + let sk = SecretKey::from_seed(&mnemonic.to_seed("")); + let pk = sk.public_key(); + + let fingerprint = keychain.add_mnemonic(&mnemonic, PASSWORD).unwrap(); + let extracted_pk = keychain.extract_public_key(fingerprint).unwrap(); + + assert_eq!(extracted_pk.unwrap(), pk); + } + + #[test] + fn serialization_round_trip() { + let mut keychain = Keychain::default(); + let mnemonic = test_mnemonic(); + let fingerprint = keychain.add_mnemonic(&mnemonic, PASSWORD).unwrap(); + + let bytes = keychain.to_bytes().unwrap(); + let restored = Keychain::from_bytes(&bytes).unwrap(); + + assert!(restored.contains(fingerprint)); + assert!(restored.has_secret_key(fingerprint)); + + let (extracted_mnemonic, _) = + restored.extract_secrets(fingerprint, PASSWORD).unwrap(); + assert_eq!( + extracted_mnemonic.unwrap().to_string(), + mnemonic.to_string() + ); + } + + #[test] + fn extract_nonexistent_key() { + let keychain = Keychain::default(); + let (mnemonic, sk) = keychain.extract_secrets(999999, PASSWORD).unwrap(); + assert!(mnemonic.is_none()); + assert!(sk.is_none()); + } +} diff --git a/crates/sage/src/utils/parse.rs b/crates/sage/src/utils/parse.rs index d5de2e5ab..2a7de20cf 100644 --- a/crates/sage/src/utils/parse.rs +++ b/crates/sage/src/utils/parse.rs @@ -180,36 +180,195 @@ mod tests { use super::*; + // ─── parse_signature_message ─── + #[test] - fn test_parse_signature_message() { - // Test hex string with 0x prefix + fn test_parse_signature_message_hex_with_prefix() { let input = "0x1234567890abcdef"; let result = parse_signature_message(input.to_string()).unwrap(); let expected = Bytes::from(hex::decode(&input[2..]).unwrap()); assert_eq!(result, expected); + } - // Test hex string without prefix + #[test] + fn test_parse_signature_message_hex_without_prefix() { let input = "1234567890abcdef"; let result = parse_signature_message(input.to_string()).unwrap(); let expected = Bytes::from(hex::decode(input).unwrap()); assert_eq!(result, expected); + } - // Test non-hex string + #[test] + fn test_parse_signature_message_non_hex() { let input = "Hello, world!"; let result = parse_signature_message(input.to_string()).unwrap(); let expected = Bytes::from(input.as_bytes()); assert_eq!(result, expected); + } - // Test hex string with 0x prefix - let input = "0xcafe"; - let result = parse_signature_message(input.to_string()).unwrap(); - let expected = Bytes::from(hex::decode(&input[2..]).unwrap()); - assert_eq!(result, expected); - - // Test hex string without prefix + #[test] + fn test_parse_signature_message_short_hex() { let input = "cafe"; let result = parse_signature_message(input.to_string()).unwrap(); let expected = Bytes::from(hex::decode(input).unwrap()); assert_eq!(result, expected); } + + // ─── parse_asset_id ─── + + #[test] + fn test_parse_asset_id_valid() { + let hex_str = "aa".repeat(32); // 64 hex chars = 32 bytes + let result = parse_asset_id(hex_str.clone()); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Bytes32::new([0xaa; 32])); + } + + #[test] + fn test_parse_asset_id_invalid_length() { + let hex_str = "aa".repeat(16); // too short + let result = parse_asset_id(hex_str); + assert!(result.is_err()); + } + + #[test] + fn test_parse_asset_id_invalid_hex() { + let input = "zz".repeat(32); + let result = parse_asset_id(input); + assert!(result.is_err()); + } + + // ─── parse_coin_id ─── + + #[test] + fn test_parse_coin_id_without_prefix() { + let hex_str = "bb".repeat(32); + let result = parse_coin_id(hex_str); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Bytes32::new([0xbb; 32])); + } + + #[test] + fn test_parse_coin_id_with_0x_prefix() { + let hex_str = format!("0x{}", "cc".repeat(32)); + let result = parse_coin_id(hex_str); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Bytes32::new([0xcc; 32])); + } + + #[test] + fn test_parse_coin_id_invalid() { + let result = parse_coin_id("not_hex".to_string()); + assert!(result.is_err()); + } + + // ─── parse_coin_ids ─── + + #[test] + fn test_parse_coin_ids_multiple() { + let ids = vec!["aa".repeat(32), format!("0x{}", "bb".repeat(32))]; + let result = parse_coin_ids(ids); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 2); + } + + #[test] + fn test_parse_coin_ids_one_invalid() { + let ids = vec!["aa".repeat(32), "invalid".to_string()]; + let result = parse_coin_ids(ids); + assert!(result.is_err()); + } + + // ─── parse_hash ─── + + #[test] + fn test_parse_hash_valid() { + let hex_str = "dd".repeat(32); + let result = parse_hash(hex_str); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_hash_with_0x() { + let hex_str = format!("0x{}", "ee".repeat(32)); + let result = parse_hash(hex_str); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_hash_invalid_length() { + let result = parse_hash("abcd".to_string()); + assert!(result.is_err()); + } + + // ─── parse_amount ─── + + #[test] + fn test_parse_amount_valid_integer() { + let amount: Amount = serde_json::from_str("1000").unwrap(); + let result = parse_amount(amount); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1000); + } + + #[test] + fn test_parse_amount_zero() { + let amount: Amount = serde_json::from_str("0").unwrap(); + let result = parse_amount(amount); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + } + + // ─── parse_program ─── + + #[test] + fn test_parse_program_valid() { + let result = parse_program("ff01ff02".to_string()); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_program_with_0x() { + let result = parse_program("0xff01".to_string()); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_program_invalid_hex() { + let result = parse_program("zzz".to_string()); + assert!(result.is_err()); + } + + // ─── parse_memos ─── + + #[test] + fn test_parse_memos_valid() { + let memos = vec!["aabb".to_string(), "ccdd".to_string()]; + let result = parse_memos(memos); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 2); + } + + #[test] + fn test_parse_memos_empty() { + let result = parse_memos(vec![]); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_parse_memos_invalid_hex() { + let memos = vec!["not_hex".to_string()]; + let result = parse_memos(memos); + assert!(result.is_err()); + } + + // ─── parse_any_asset_id routing ─── + + #[test] + fn test_parse_any_asset_id_hex() { + let hex_str = "aa".repeat(32); + let result = parse_any_asset_id(hex_str); + assert!(result.is_ok()); + } } diff --git a/package.json b/package.json index 7d0f9fc84..42674c978 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "prettier:check": "prettier --check .", "extract": "lingui extract", "compile": "lingui compile", - "extract:watch": "lingui extract --watch" + "extract:watch": "lingui extract --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@buildyourwebapp/tauri-plugin-sharesheet": "^0.0.1", @@ -87,6 +90,9 @@ "@lingui/swc-plugin": "^5.10.1", "@lingui/vite-plugin": "^5.9.0", "@tauri-apps/cli": "^2.10.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.19.10", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", @@ -99,11 +105,13 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.26", + "jsdom": "^28.0.0", "postcss": "^8.5.6", "prettier": "^3.8.1", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9aef00af..52bc93812 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,15 @@ importers: '@tauri-apps/cli': specifier: ^2.10.0 version: 2.10.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^22.19.10 version: 22.19.10 @@ -252,6 +261,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.26 version: 0.4.26(eslint@8.57.1) + jsdom: + specifier: ^28.0.0 + version: 28.0.0(@noble/hashes@1.8.0) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -270,9 +282,18 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.10)(jiti@1.21.7)(jsdom@28.0.0(@noble/hashes@1.8.0))(yaml@2.5.1) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -280,6 +301,15 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + + '@asamuzakjp/dom-selector@6.7.8': + resolution: {integrity: sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -355,6 +385,37 @@ packages: resolution: {integrity: sha512-bVbf4shK4hq9g2O3qpbUhcqHzeFhI15KsJQqBCDI74ey0eHavQ3L7I+fwlsuZA5iedNAcN1iB62Kjm7V4uT8Jg==} engines: {pnpm: ^9.0.0} + '@csstools/color-helpers@6.0.1': + resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.0.1': + resolution: {integrity: sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.1': + resolution: {integrity: sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.27': + resolution: {integrity: sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -726,6 +787,15 @@ packages: resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.12.0': + resolution: {integrity: sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -1533,6 +1603,9 @@ packages: '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/core-darwin-arm64@1.15.11': resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==} engines: {node: '>=10'} @@ -1727,6 +1800,44 @@ packages: '@tauri-apps/plugin-os@2.3.2': resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1895,6 +2006,35 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@walletconnect/core@2.23.4': resolution: {integrity: sha512-qkzNvRfibl+r2GoPqKl+2MJLYA7ApEWyCmECJoK6IExeWyjKawAUC6Eo4cN0geCBefk9VSFRFEIVQ17vYWp0jQ==} engines: {node: '>=18.20.8'} @@ -1979,6 +2119,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -2011,6 +2155,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -2043,6 +2194,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2082,6 +2237,9 @@ packages: bech32@2.0.0: resolution: {integrity: sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -2143,6 +2301,10 @@ packages: caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2239,14 +2401,29 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2271,6 +2448,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2288,6 +2468,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -2315,6 +2499,12 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2325,6 +2515,10 @@ packages: emoji-mart@5.6.0: resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2344,6 +2538,9 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2442,6 +2639,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2453,6 +2653,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2631,6 +2835,18 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + idb-keyval@6.2.2: resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} @@ -2653,6 +2869,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -2750,6 +2970,9 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2830,6 +3053,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.0.0: + resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2907,10 +3139,20 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2923,6 +3165,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.1.2: resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} @@ -3012,6 +3258,9 @@ packages: observable-fns@0.6.1: resolution: {integrity: sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -3065,6 +3314,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3088,6 +3340,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3184,6 +3439,10 @@ packages: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3233,6 +3492,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -3318,6 +3580,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3326,6 +3592,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3379,6 +3649,10 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -3427,6 +3701,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3462,6 +3739,12 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3492,6 +3775,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3509,6 +3796,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -3553,14 +3843,40 @@ packages: tiny-worker@2.3.0: resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -3634,6 +3950,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.21.0: + resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} + engines: {node: '>=20.18.1'} + unstorage@1.17.4: resolution: {integrity: sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==} peerDependencies: @@ -3779,9 +4099,59 @@ packages: yaml: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.0: + resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3803,6 +4173,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3822,6 +4197,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3858,10 +4240,32 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + '@adraffy/ens-normalize@1.11.1': {} '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.5 + + '@asamuzakjp/dom-selector@6.7.8': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.5 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3968,6 +4372,28 @@ snapshots: dependencies: '@tauri-apps/api': 2.0.0-rc.0 + '@csstools/color-helpers@6.0.1': {} + + '@csstools/css-calc@3.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.1 + '@csstools/css-calc': 3.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.27': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': dependencies: react: 18.3.1 @@ -4189,6 +4615,10 @@ snapshots: '@eslint/js@9.39.2': {} + '@exodus/bytes@1.12.0(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -4984,6 +5414,8 @@ snapshots: '@sinclair/typebox@0.27.10': {} + '@standard-schema/spec@1.1.0': {} + '@swc/core-darwin-arm64@1.15.11': optional: true @@ -5131,6 +5563,49 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -5356,6 +5831,45 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + '@walletconnect/core@2.23.4(typescript@5.9.3)(zod@3.25.76)': dependencies: '@walletconnect/heartbeat': 1.2.2 @@ -5622,6 +6136,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -5652,6 +6168,12 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -5711,6 +6233,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + async-function@1.0.0: {} atomic-sleep@1.0.0: {} @@ -5745,6 +6269,10 @@ snapshots: bech32@2.0.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bignumber.js@9.3.1: {} binary-extensions@2.3.0: {} @@ -5812,6 +6340,8 @@ snapshots: caniuse-lite@1.0.30001769: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5923,10 +6453,31 @@ snapshots: dependencies: uncrypto: 0.1.3 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + cssesc@3.0.0: {} + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.0.27 + css-tree: 3.1.0 + lru-cache: 11.2.5 + csstype@3.2.3: {} + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -5951,6 +6502,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} defaults@1.0.4: @@ -5971,6 +6524,8 @@ snapshots: defu@6.1.4: {} + dequal@2.0.3: {} + destr@2.0.5: {} detect-browser@5.3.0: {} @@ -5993,6 +6548,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6003,6 +6562,8 @@ snapshots: emoji-mart@5.6.0: {} + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -6087,6 +6648,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6282,12 +6845,18 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@5.0.1: {} events@3.3.0: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -6482,6 +7051,26 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.12.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + idb-keyval@6.2.2: {} ieee754@1.2.1: {} @@ -6497,6 +7086,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -6593,6 +7184,8 @@ snapshots: is-path-inside@3.0.3: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -6674,6 +7267,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.0.0(@noble/hashes@1.8.0): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.7.8 + '@exodus/bytes': 1.12.0(@noble/hashes@1.8.0) + cssstyle: 5.3.7 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.21.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.0(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -6737,8 +7356,16 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + mdn-data@2.12.2: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -6748,6 +7375,8 @@ snapshots: mimic-fn@2.1.0: {} + min-indent@1.0.1: {} + minimatch@10.1.2: dependencies: '@isaacs/brace-expansion': 5.0.1 @@ -6832,6 +7461,8 @@ snapshots: observable-fns@0.6.1: {} + obug@2.1.1: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -6911,6 +7542,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -6926,6 +7561,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -7004,6 +7641,12 @@ snapshots: pretty-bytes@7.1.0: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -7048,6 +7691,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-number-format@5.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -7126,6 +7771,11 @@ snapshots: real-require@0.2.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -7146,6 +7796,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve@1.22.11: @@ -7229,6 +7881,10 @@ snapshots: safe-stable-stringify@2.5.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -7293,6 +7949,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -7317,6 +7975,10 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -7374,6 +8036,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} sucrase@3.35.1: @@ -7392,6 +8058,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwind-merge@2.6.1: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.19(yaml@2.5.1)): @@ -7470,15 +8138,35 @@ snapshots: esm: 3.2.25 optional: true + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@1.4.3(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -7562,6 +8250,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.21.0: {} + unstorage@1.17.4(idb-keyval@6.2.2): dependencies: anymatch: 3.1.3 @@ -7625,10 +8315,64 @@ snapshots: jiti: 1.21.7 yaml: 2.5.1 + vitest@4.0.18(@types/node@22.19.10)(jiti@1.21.7)(jsdom@28.0.0(@noble/hashes@1.8.0))(yaml@2.5.1): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.10 + jsdom: 28.0.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.12.0(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -7674,12 +8418,21 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrappy@1.0.2: {} ws@7.5.10: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yaml@1.10.2: diff --git a/src/__tests__/helpers/renderWithProviders.tsx b/src/__tests__/helpers/renderWithProviders.tsx new file mode 100644 index 000000000..71c0e5bb4 --- /dev/null +++ b/src/__tests__/helpers/renderWithProviders.tsx @@ -0,0 +1,24 @@ +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { render, RenderOptions } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +// Activate an empty en-US catalog +i18n.load('en', {}); +i18n.activate('en'); + +function AllProviders({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function renderWithProviders( + ui: ReactElement, + options?: Omit, +) { + return render(ui, { wrapper: AllProviders, ...options }); +} diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 000000000..9b3c6b536 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,89 @@ +import '@testing-library/jest-dom/vitest'; +import { vi } from 'vitest'; + +// Mock @tauri-apps/api/core — invoke should throw by default to catch unmocked calls +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(() => { + throw new Error('Unmocked Tauri invoke call'); + }), + Channel: vi.fn(), + transformCallback: vi.fn(), +})); + +// Mock @tauri-apps/api/event +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(async () => vi.fn()), + once: vi.fn(async () => vi.fn()), + emit: vi.fn(), + TauriEvent: {}, +})); + +// Mock @tauri-apps/api/webviewWindow +vi.mock('@tauri-apps/api/webviewWindow', () => ({ + getCurrentWebviewWindow: vi.fn(() => ({ + listen: vi.fn(async () => vi.fn()), + once: vi.fn(async () => vi.fn()), + emit: vi.fn(), + })), + WebviewWindow: vi.fn(), +})); + +// Mock @tauri-apps/api/window +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: vi.fn(() => ({ + listen: vi.fn(async () => vi.fn()), + setTitle: vi.fn(), + close: vi.fn(), + minimize: vi.fn(), + toggleMaximize: vi.fn(), + })), + Window: vi.fn(), +})); + +// Mock Tauri plugins +vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ + writeText: vi.fn(), + readText: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-os', () => ({ + platform: vi.fn(async () => 'macos'), + type: vi.fn(async () => 'Darwin'), + arch: vi.fn(async () => 'aarch64'), +})); + +vi.mock('@tauri-apps/plugin-opener', () => ({ + open: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: vi.fn(), + save: vi.fn(), + message: vi.fn(), + ask: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-fs', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + exists: vi.fn(), + mkdir: vi.fn(), + readDir: vi.fn(), + remove: vi.fn(), + rename: vi.fn(), + BaseDirectory: {}, +})); + +vi.mock('@tauri-apps/plugin-biometric', () => ({ + authenticate: vi.fn(), + checkStatus: vi.fn(), + BiometricAuth: {}, +})); + +vi.mock('@tauri-apps/plugin-barcode-scanner', () => ({ + scan: vi.fn(), + Format: {}, +})); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 000000000..660bcf3b6 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from 'vitest'; +import BigNumber from 'bignumber.js'; +import { + toMojos, + fromMojos, + toDecimal, + toAddress, + addressInfo, + isValidAddress, + puzzleHash, + toHex, + isHex, + formatAddress, + formatUsdPrice, + isValidUrl, + isValidAssetId, + deepMerge, + decodeHexMessage, +} from './utils'; + +// A known puzzle-hash for round-trip tests (32 bytes = 64 hex chars) +const KNOWN_PUZZLE_HASH = + 'a0b0c0d0e0f0a1b1c1d1e1f1a2b2c2d2e2f2a3b3c3d3e3f3a4b4c4d4e4f4a5b5'; + +describe('toMojos / fromMojos / toDecimal', () => { + it('converts XCH amount with 12 decimal precision', () => { + expect(toMojos('1', 12)).toBe('1000000000000'); + expect(toMojos('0.5', 12)).toBe('500000000000'); + expect(toDecimal('1000000000000', 12)).toBe('1'); + expect(toDecimal('500000000000', 12)).toBe('0.5'); + }); + + it('converts CAT amount with 3 decimal precision', () => { + expect(toMojos('1', 3)).toBe('1000'); + expect(toMojos('0.5', 3)).toBe('500'); + expect(toDecimal('1000', 3)).toBe('1'); + expect(toDecimal('500', 3)).toBe('0.5'); + }); + + it('handles zero', () => { + expect(toMojos('0', 12)).toBe('0'); + expect(toDecimal('0', 12)).toBe('0'); + expect(fromMojos(0, 12).isEqualTo(0)).toBe(true); + }); + + it('handles large amounts without scientific notation', () => { + const result = toMojos('1000000', 12); + expect(result).toBe('1000000000000000000'); + expect(result).not.toContain('e'); + }); + + it('handles very small amounts', () => { + expect(toMojos('0.000000000001', 12)).toBe('1'); + // BigNumber.toString() uses exponential notation for very small values + expect(fromMojos('1', 12).isEqualTo('0.000000000001')).toBe(true); + }); + + it('fromMojos returns a BigNumber', () => { + const result = fromMojos('1000', 3); + expect(result).toBeInstanceOf(BigNumber); + expect(result.toString()).toBe('1'); + }); +}); + +describe('toAddress / addressInfo / puzzleHash / isValidAddress', () => { + it('round-trips puzzle hash → address → puzzle hash (xch)', () => { + const addr = toAddress(KNOWN_PUZZLE_HASH, 'xch'); + expect(addr.startsWith('xch')).toBe(true); + const info = addressInfo(addr); + expect(info.puzzleHash).toBe(KNOWN_PUZZLE_HASH); + expect(info.prefix).toBe('xch'); + }); + + it('round-trips with txch prefix', () => { + const addr = toAddress(KNOWN_PUZZLE_HASH, 'txch'); + expect(addr.startsWith('txch')).toBe(true); + const info = addressInfo(addr); + expect(info.puzzleHash).toBe(KNOWN_PUZZLE_HASH); + expect(info.prefix).toBe('txch'); + }); + + it('puzzleHash extracts from address', () => { + const addr = toAddress(KNOWN_PUZZLE_HASH, 'xch'); + expect(puzzleHash(addr)).toBe(KNOWN_PUZZLE_HASH); + }); + + it('isValidAddress accepts valid xch address', () => { + const addr = toAddress(KNOWN_PUZZLE_HASH, 'xch'); + expect(isValidAddress(addr, 'xch')).toBe(true); + }); + + it('isValidAddress rejects wrong prefix', () => { + const addr = toAddress(KNOWN_PUZZLE_HASH, 'xch'); + expect(isValidAddress(addr, 'txch')).toBe(false); + }); + + it('isValidAddress rejects garbage input', () => { + expect(isValidAddress('not-an-address', 'xch')).toBe(false); + expect(isValidAddress('', 'xch')).toBe(false); + }); + + it('handles 0x-prefixed puzzle hash', () => { + const addr = toAddress('0x' + KNOWN_PUZZLE_HASH, 'xch'); + const info = addressInfo(addr); + expect(info.puzzleHash).toBe(KNOWN_PUZZLE_HASH); + }); +}); + +describe('toHex / isHex', () => { + it('converts bytes to hex', () => { + expect(toHex(new Uint8Array([0, 1, 255]))).toBe('0001ff'); + expect(toHex(new Uint8Array([]))).toBe(''); + }); + + it('isHex validates hex strings', () => { + expect(isHex('abcdef0123456789')).toBe(true); + expect(isHex('ABCDEF')).toBe(true); + expect(isHex('0xabcdef')).toBe(true); + }); + + it('isHex rejects non-hex strings', () => { + expect(isHex('xyz')).toBe(false); + expect(isHex('')).toBe(false); + expect(isHex('0xgh')).toBe(false); + }); +}); + +describe('decodeHexMessage', () => { + it('decodes hex-encoded ASCII message', () => { + expect(decodeHexMessage('48656c6c6f')).toBe('Hello'); + }); + + it('handles 0x prefix', () => { + expect(decodeHexMessage('0x48656c6c6f')).toBe('Hello'); + }); +}); + +describe('formatAddress', () => { + it('truncates long addresses', () => { + const addr = 'abcdefghijklmnopqrstuvwxyz'; + expect(formatAddress(addr, 4, 4)).toBe('abcd...wxyz'); + }); + + it('returns full address when shorter than chars + trailingChars', () => { + expect(formatAddress('short', 8, 8)).toBe('short'); + }); + + it('strips 0x prefix before truncating', () => { + // 0x prefix is stripped, remaining "abcdefghijklmnop" is 16 chars + // which equals 8 + 8, so it returns the full original address + const addr = '0xabcdefghijklmnop'; + expect(formatAddress(addr)).toBe(addr); + + // With a longer address the 0x is stripped and the rest is truncated + const longAddr = '0x' + 'a'.repeat(30); + const result = formatAddress(longAddr); + expect(result.startsWith('aaaaaaaa...')).toBe(true); + }); + + it('uses default chars=8', () => { + const addr = 'a'.repeat(30); + const result = formatAddress(addr); + expect(result).toBe('aaaaaaaa...aaaaaaaa'); + }); +}); + +describe('formatUsdPrice', () => { + it('formats sub-cent prices', () => { + expect(formatUsdPrice(0.001)).toBe('< 0.01¢'); + expect(formatUsdPrice(0.009)).toBe('< 0.01¢'); + }); + + it('formats cent-level prices', () => { + expect(formatUsdPrice(0.5)).toBe('50.00¢'); + expect(formatUsdPrice(0.01)).toBe('1.00¢'); + expect(formatUsdPrice(0.99)).toBe('99.00¢'); + }); + + it('formats dollar prices', () => { + expect(formatUsdPrice(1)).toBe('$1.00'); + expect(formatUsdPrice(42.5)).toBe('$42.50'); + expect(formatUsdPrice(1000)).toBe('$1000.00'); + }); +}); + +describe('isValidUrl', () => { + it('accepts https URLs', () => { + expect(isValidUrl('https://example.com')).toBe(true); + expect(isValidUrl('https://example.com/path?q=1')).toBe(true); + }); + + it('accepts http URLs', () => { + expect(isValidUrl('http://example.com')).toBe(true); + }); + + it('rejects localhost', () => { + expect(isValidUrl('http://localhost')).toBe(false); + expect(isValidUrl('https://localhost:3000')).toBe(false); + }); + + it('rejects 127.0.0.1', () => { + expect(isValidUrl('http://127.0.0.1')).toBe(false); + expect(isValidUrl('https://127.0.0.1:8080')).toBe(false); + }); + + it('rejects file:// protocol', () => { + expect(isValidUrl('file:///etc/passwd')).toBeFalsy(); + }); + + it('rejects ftp:// protocol', () => { + expect(isValidUrl('ftp://example.com')).toBeFalsy(); + }); + + it('rejects garbage strings', () => { + expect(isValidUrl('not-a-url')).toBeFalsy(); + expect(isValidUrl('')).toBeFalsy(); + }); +}); + +describe('isValidAssetId', () => { + it('accepts valid 64-char hex', () => { + expect(isValidAssetId('a'.repeat(64))).toBe(true); + expect(isValidAssetId('0123456789abcdefABCDEF' + 'a'.repeat(42))).toBe( + true, + ); + }); + + it('rejects too-short strings', () => { + expect(isValidAssetId('abc')).toBe(false); + expect(isValidAssetId('a'.repeat(63))).toBe(false); + }); + + it('rejects too-long strings', () => { + expect(isValidAssetId('a'.repeat(65))).toBe(false); + }); + + it('rejects non-hex characters', () => { + expect(isValidAssetId('g'.repeat(64))).toBe(false); + expect(isValidAssetId('z' + 'a'.repeat(63))).toBe(false); + }); +}); + +describe('deepMerge', () => { + it('merges nested objects', () => { + const target = { a: 1, b: { c: 2, d: 3 } }; + const source = { b: { c: 10 } }; + const result = deepMerge(target, source); + expect(result).toEqual({ a: 1, b: { c: 10, d: 3 } }); + }); + + it('does not mutate the original target', () => { + const target = { a: { b: 1 } }; + const source = { a: { b: 2 } }; + const result = deepMerge(target, source); + expect(result.a.b).toBe(2); + expect(target.a.b).toBe(1); + }); + + it('replaces arrays instead of merging them', () => { + const target = { arr: [1, 2, 3] }; + const source = { arr: [4, 5] }; + const result = deepMerge(target, source); + expect(result.arr).toEqual([4, 5]); + }); + + it('adds new keys from source', () => { + const target = { a: 1 } as Record; + const source = { b: 2 }; + const result = deepMerge(target, source); + expect(result).toEqual({ a: 1, b: 2 }); + }); +}); diff --git a/src/state.test.ts b/src/state.test.ts new file mode 100644 index 000000000..a097d9369 --- /dev/null +++ b/src/state.test.ts @@ -0,0 +1,167 @@ +import { vi, describe, expect, it, beforeEach } from 'vitest'; + +// Mock bindings before any imports that use them +vi.mock('@/bindings', () => ({ + commands: { + getKey: vi.fn(), + getSyncStatus: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + }, + events: { + syncEvent: { + listen: vi.fn(async () => vi.fn()), + }, + }, +})); + +import { commands } from '@/bindings'; +import { + useWalletState, + useOfferState, + useNavigationStore, + clearState, + loginAndUpdateState, + defaultState, +} from './state'; + +const mockCommands = vi.mocked(commands); + +describe('useWalletState', () => { + beforeEach(() => { + clearState(); + }); + + it('has correct default state', () => { + const state = useWalletState.getState(); + expect(state.sync.balance).toBe('0'); + expect(state.sync.unit.ticker).toBe('XCH'); + expect(state.sync.unit.precision).toBe(12); + expect(state.sync.receive_address).toBe('Unknown'); + expect(state.sync.total_coins).toBe(0); + expect(state.sync.synced_coins).toBe(0); + }); + + it('can be updated via setState', () => { + useWalletState.setState({ + sync: { ...defaultState().sync, balance: '1000' }, + }); + expect(useWalletState.getState().sync.balance).toBe('1000'); + }); +}); + +describe('useOfferState', () => { + beforeEach(() => { + clearState(); + }); + + it('defaults to null', () => { + expect(useOfferState.getState()).toBeNull(); + }); + + it('can be set to an offer', () => { + useOfferState.setState({ + offered: { tokens: [], nfts: [], options: [] }, + requested: { tokens: [], nfts: [], options: [] }, + fee: '0', + expiration: null, + }); + expect(useOfferState.getState()).not.toBeNull(); + expect(useOfferState.getState()?.fee).toBe('0'); + }); +}); + +describe('useNavigationStore', () => { + it('starts with empty returnValues', () => { + const state = useNavigationStore.getState(); + expect(state.returnValues).toEqual({}); + }); + + it('setReturnValue stores and retrieves values', () => { + useNavigationStore + .getState() + .setReturnValue('page1', { status: 'success', data: 'test' }); + + const state = useNavigationStore.getState(); + expect(state.returnValues['page1']).toEqual({ + status: 'success', + data: 'test', + }); + }); + + it('setReturnValue preserves existing values', () => { + useNavigationStore + .getState() + .setReturnValue('page1', { status: 'success' }); + useNavigationStore + .getState() + .setReturnValue('page2', { status: 'cancelled' }); + + const state = useNavigationStore.getState(); + expect(state.returnValues['page1']?.status).toBe('success'); + expect(state.returnValues['page2']?.status).toBe('cancelled'); + }); +}); + +describe('clearState', () => { + it('resets wallet state to defaults', () => { + useWalletState.setState({ + sync: { ...defaultState().sync, balance: '999' }, + }); + clearState(); + expect(useWalletState.getState().sync.balance).toBe('0'); + }); + + it('resets offer state to null', () => { + useOfferState.setState({ + offered: { tokens: [], nfts: [], options: [] }, + requested: { tokens: [], nfts: [], options: [] }, + fee: '100', + expiration: null, + }); + clearState(); + expect(useOfferState.getState()).toBeNull(); + }); +}); + +describe('loginAndUpdateState', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearState(); + }); + + it('calls login then fetches sync status', async () => { + const syncResponse = { + ...defaultState().sync, + balance: '42000', + receive_address: 'xch1abc', + }; + + mockCommands.login.mockResolvedValue(undefined as never); + mockCommands.getKey.mockResolvedValue({ key: { fingerprint: 123 } } as never); + mockCommands.getSyncStatus.mockResolvedValue(syncResponse as never); + + await loginAndUpdateState(123); + + expect(mockCommands.login).toHaveBeenCalledWith({ fingerprint: 123 }); + expect(mockCommands.getKey).toHaveBeenCalled(); + expect(mockCommands.getSyncStatus).toHaveBeenCalled(); + }); + + it('calls onError when login fails', async () => { + const mockError = { kind: 'test', reason: 'fail' }; + mockCommands.login.mockRejectedValue(mockError); + + const onError = vi.fn(); + + await expect(loginAndUpdateState(123, onError)).rejects.toBe(mockError); + expect(onError).toHaveBeenCalledWith(mockError); + }); + + it('throws without onError when login fails', async () => { + const mockError = new Error('login failed'); + mockCommands.login.mockRejectedValue(mockError); + + await expect(loginAndUpdateState(123)).rejects.toThrow('login failed'); + }); +}); diff --git a/src/validation.test.ts b/src/validation.test.ts new file mode 100644 index 000000000..7f49aedf0 --- /dev/null +++ b/src/validation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { isValidU32 } from './validation'; + +describe('isValidU32', () => { + it('accepts zero', () => { + expect(isValidU32(0)).toBe(true); + }); + + it('accepts max u32 - 1', () => { + expect(isValidU32(2 ** 32 - 1)).toBe(true); + }); + + it('rejects 2^32 (overflow)', () => { + expect(isValidU32(2 ** 32)).toBe(false); + }); + + it('rejects negative numbers', () => { + expect(isValidU32(-1)).toBe(false); + }); + + it('rejects NaN', () => { + expect(isValidU32(NaN)).toBe(false); + }); + + it('rejects Infinity', () => { + expect(isValidU32(Infinity)).toBe(false); + expect(isValidU32(-Infinity)).toBe(false); + }); + + it('respects custom minimum', () => { + expect(isValidU32(0, 1)).toBe(false); + expect(isValidU32(1, 1)).toBe(true); + expect(isValidU32(100, 50)).toBe(true); + expect(isValidU32(49, 50)).toBe(false); + }); + + it('accepts typical u32 values', () => { + expect(isValidU32(1)).toBe(true); + expect(isValidU32(1000)).toBe(true); + expect(isValidU32(2147483647)).toBe(true); // max i32 + }); +}); diff --git a/src/walletconnect/commands.test.ts b/src/walletconnect/commands.test.ts new file mode 100644 index 000000000..572d3080b --- /dev/null +++ b/src/walletconnect/commands.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, it } from 'vitest'; +import { parseCommand, walletConnectCommands } from './commands'; +import { ZodError } from 'zod'; + +describe('parseCommand', () => { + // ─── chip0002_chainId ─── + describe('chip0002_chainId', () => { + it('accepts empty object', () => { + expect(() => parseCommand('chip0002_chainId', {})).not.toThrow(); + }); + + it('accepts undefined', () => { + expect(() => parseCommand('chip0002_chainId', undefined)).not.toThrow(); + }); + }); + + // ─── chip0002_connect ─── + describe('chip0002_connect', () => { + it('accepts empty object', () => { + expect(() => parseCommand('chip0002_connect', {})).not.toThrow(); + }); + + it('accepts eager flag', () => { + const result = parseCommand('chip0002_connect', { eager: true }); + expect(result?.eager).toBe(true); + }); + + it('accepts undefined', () => { + expect(() => parseCommand('chip0002_connect', undefined)).not.toThrow(); + }); + }); + + // ─── chip0002_getPublicKeys ─── + describe('chip0002_getPublicKeys', () => { + it('accepts limit and offset', () => { + const result = parseCommand('chip0002_getPublicKeys', { + limit: 5, + offset: 10, + }); + expect(result?.limit).toBe(5); + expect(result?.offset).toBe(10); + }); + + it('accepts empty object', () => { + expect(() => + parseCommand('chip0002_getPublicKeys', {}), + ).not.toThrow(); + }); + + it('accepts undefined', () => { + expect(() => + parseCommand('chip0002_getPublicKeys', undefined), + ).not.toThrow(); + }); + + it('rejects string limit', () => { + expect(() => + parseCommand('chip0002_getPublicKeys', { limit: 'five' }), + ).toThrow(ZodError); + }); + }); + + // ─── chip0002_filterUnlockedCoins ─── + describe('chip0002_filterUnlockedCoins', () => { + it('accepts valid coinNames', () => { + const result = parseCommand('chip0002_filterUnlockedCoins', { + coinNames: ['abc123'], + }); + expect(result.coinNames).toEqual(['abc123']); + }); + + it('rejects empty coinNames array', () => { + expect(() => + parseCommand('chip0002_filterUnlockedCoins', { coinNames: [] }), + ).toThrow(ZodError); + }); + + it('rejects missing coinNames', () => { + expect(() => + parseCommand('chip0002_filterUnlockedCoins', {}), + ).toThrow(ZodError); + }); + }); + + // ─── chip0002_getAssetCoins ─── + describe('chip0002_getAssetCoins', () => { + it('accepts cat type', () => { + const result = parseCommand('chip0002_getAssetCoins', { + type: 'cat', + assetId: 'abc', + }); + expect(result.type).toBe('cat'); + }); + + it('accepts null type', () => { + const result = parseCommand('chip0002_getAssetCoins', { + type: null, + assetId: null, + }); + expect(result.type).toBeNull(); + }); + + it('rejects invalid type', () => { + expect(() => + parseCommand('chip0002_getAssetCoins', { + type: 'invalid', + assetId: null, + }), + ).toThrow(ZodError); + }); + + it('accepts optional includedLocked, offset, limit', () => { + const result = parseCommand('chip0002_getAssetCoins', { + type: 'nft', + assetId: 'xyz', + includedLocked: true, + offset: 0, + limit: 50, + }); + expect(result.includedLocked).toBe(true); + expect(result.limit).toBe(50); + }); + }); + + // ─── chip0002_getAssetBalance ─── + describe('chip0002_getAssetBalance', () => { + it('accepts valid params', () => { + const result = parseCommand('chip0002_getAssetBalance', { + type: 'cat', + assetId: 'abc', + }); + expect(result.type).toBe('cat'); + expect(result.assetId).toBe('abc'); + }); + + it('accepts null type and assetId', () => { + const result = parseCommand('chip0002_getAssetBalance', { + type: null, + assetId: null, + }); + expect(result.type).toBeNull(); + }); + + it('rejects missing type', () => { + expect(() => + parseCommand('chip0002_getAssetBalance', { assetId: 'abc' }), + ).toThrow(ZodError); + }); + }); + + // ─── chip0002_signCoinSpends ─── + describe('chip0002_signCoinSpends', () => { + const validCoinSpend = { + coin: { parent_coin_info: 'abc', puzzle_hash: 'def', amount: 100 }, + puzzle_reveal: 'ff01', + solution: 'ff02', + }; + + it('accepts valid coin spends', () => { + const result = parseCommand('chip0002_signCoinSpends', { + coinSpends: [validCoinSpend], + }); + expect(result.coinSpends).toHaveLength(1); + }); + + it('accepts optional partialSign', () => { + const result = parseCommand('chip0002_signCoinSpends', { + coinSpends: [validCoinSpend], + partialSign: true, + }); + expect(result.partialSign).toBe(true); + }); + + it('rejects missing coinSpends', () => { + expect(() => parseCommand('chip0002_signCoinSpends', {})).toThrow( + ZodError, + ); + }); + + it('requires confirm', () => { + expect(walletConnectCommands.chip0002_signCoinSpends.confirm).toBe(true); + }); + }); + + // ─── chip0002_signMessage ─── + describe('chip0002_signMessage', () => { + it('accepts valid params', () => { + const result = parseCommand('chip0002_signMessage', { + message: 'hello', + publicKey: 'abc123', + }); + expect(result.message).toBe('hello'); + }); + + it('rejects missing message', () => { + expect(() => + parseCommand('chip0002_signMessage', { publicKey: 'abc' }), + ).toThrow(ZodError); + }); + + it('requires confirm', () => { + expect(walletConnectCommands.chip0002_signMessage.confirm).toBe(true); + }); + }); + + // ─── chip0002_sendTransaction ─── + describe('chip0002_sendTransaction', () => { + const validBundle = { + coin_spends: [ + { + coin: { parent_coin_info: 'a', puzzle_hash: 'b', amount: 1 }, + puzzle_reveal: 'ff', + solution: 'ff', + }, + ], + aggregated_signature: 'sig', + }; + + it('accepts valid spend bundle', () => { + const result = parseCommand('chip0002_sendTransaction', { + spendBundle: validBundle, + }); + expect(result.spendBundle.aggregated_signature).toBe('sig'); + }); + + it('rejects missing spendBundle', () => { + expect(() => parseCommand('chip0002_sendTransaction', {})).toThrow( + ZodError, + ); + }); + + it('does not require confirm', () => { + expect(walletConnectCommands.chip0002_sendTransaction.confirm).toBe( + false, + ); + }); + }); + + // ─── chia_createOffer ─── + describe('chia_createOffer', () => { + it('accepts valid offer params', () => { + const result = parseCommand('chia_createOffer', { + offerAssets: [{ assetId: '', amount: 1000 }], + requestAssets: [{ assetId: 'cat123', amount: '500' }], + }); + expect(result.offerAssets).toHaveLength(1); + expect(result.requestAssets).toHaveLength(1); + }); + + it('accepts string amounts (safeAmount)', () => { + const result = parseCommand('chia_createOffer', { + offerAssets: [{ assetId: '', amount: '1000' }], + requestAssets: [], + fee: '100', + }); + expect(result.fee).toBe('100'); + }); + + it('rejects missing offerAssets', () => { + expect(() => + parseCommand('chia_createOffer', { requestAssets: [] }), + ).toThrow(ZodError); + }); + + it('requires confirm', () => { + expect(walletConnectCommands.chia_createOffer.confirm).toBe(true); + }); + }); + + // ─── chia_takeOffer ─── + describe('chia_takeOffer', () => { + it('accepts valid params', () => { + const result = parseCommand('chia_takeOffer', { + offer: 'offer1...', + }); + expect(result.offer).toBe('offer1...'); + }); + + it('accepts optional fee', () => { + const result = parseCommand('chia_takeOffer', { + offer: 'offer1...', + fee: 100, + }); + expect(result.fee).toBe(100); + }); + + it('requires confirm', () => { + expect(walletConnectCommands.chia_takeOffer.confirm).toBe(true); + }); + }); + + // ─── chia_cancelOffer ─── + describe('chia_cancelOffer', () => { + it('accepts valid params', () => { + const result = parseCommand('chia_cancelOffer', { + id: 'offer-id-123', + }); + expect(result.id).toBe('offer-id-123'); + }); + + it('requires confirm', () => { + expect(walletConnectCommands.chia_cancelOffer.confirm).toBe(true); + }); + }); + + // ─── chia_getNfts ─── + describe('chia_getNfts', () => { + it('accepts limit and offset', () => { + const result = parseCommand('chia_getNfts', { + limit: 20, + offset: 5, + }); + expect(result.limit).toBe(20); + }); + + it('accepts collectionId', () => { + const result = parseCommand('chia_getNfts', { + collectionId: 'col123', + }); + expect(result.collectionId).toBe('col123'); + }); + + it('accepts empty object', () => { + expect(() => parseCommand('chia_getNfts', {})).not.toThrow(); + }); + + it('does not require confirm', () => { + expect(walletConnectCommands.chia_getNfts.confirm).toBe(false); + }); + }); + + // ─── chia_send ─── + describe('chia_send', () => { + it('accepts XCH send (no assetId)', () => { + const result = parseCommand('chia_send', { + amount: 1000, + address: 'xch1...', + }); + expect(result.assetId).toBeUndefined(); + }); + + it('accepts CAT send (with assetId)', () => { + const result = parseCommand('chia_send', { + assetId: 'abc', + amount: '500', + address: 'xch1...', + }); + expect(result.assetId).toBe('abc'); + }); + + it('accepts optional memos', () => { + const result = parseCommand('chia_send', { + amount: 1, + address: 'xch1...', + memos: ['hello', 'world'], + }); + expect(result.memos).toEqual(['hello', 'world']); + }); + + it('rejects missing address', () => { + expect(() => + parseCommand('chia_send', { amount: 1 }), + ).toThrow(ZodError); + }); + + it('requires confirm', () => { + expect(walletConnectCommands.chia_send.confirm).toBe(true); + }); + }); + + // ─── chia_bulkMintNfts ─── + describe('chia_bulkMintNfts', () => { + it('accepts valid mint params', () => { + const result = parseCommand('chia_bulkMintNfts', { + did: 'did:chia:...', + nfts: [ + { + dataUris: ['https://example.com/image.png'], + dataHash: 'abc123', + }, + ], + }); + expect(result.nfts).toHaveLength(1); + }); + + it('rejects missing did', () => { + expect(() => + parseCommand('chia_bulkMintNfts', { nfts: [] }), + ).toThrow(ZodError); + }); + + it('requires confirm', () => { + expect(walletConnectCommands.chia_bulkMintNfts.confirm).toBe(true); + }); + }); + + // ─── chia_getAddress ─── + describe('chia_getAddress', () => { + it('accepts empty object', () => { + expect(() => parseCommand('chia_getAddress', {})).not.toThrow(); + }); + + it('does not require confirm', () => { + expect(walletConnectCommands.chia_getAddress.confirm).toBe(false); + }); + }); + + // ─── chia_signMessageByAddress ─── + describe('chia_signMessageByAddress', () => { + it('accepts valid params', () => { + const result = parseCommand('chia_signMessageByAddress', { + message: 'test', + address: 'xch1...', + }); + expect(result.message).toBe('test'); + expect(result.address).toBe('xch1...'); + }); + + it('rejects missing address', () => { + expect(() => + parseCommand('chia_signMessageByAddress', { message: 'test' }), + ).toThrow(ZodError); + }); + + it('requires confirm', () => { + expect(walletConnectCommands.chia_signMessageByAddress.confirm).toBe( + true, + ); + }); + }); +}); + +describe('confirm flags', () => { + const readOnlyCommands: (keyof typeof walletConnectCommands)[] = [ + 'chip0002_chainId', + 'chip0002_connect', + 'chip0002_getPublicKeys', + 'chip0002_filterUnlockedCoins', + 'chip0002_getAssetCoins', + 'chip0002_getAssetBalance', + 'chip0002_sendTransaction', + 'chia_getNfts', + 'chia_getAddress', + ]; + + const confirmCommands: (keyof typeof walletConnectCommands)[] = [ + 'chip0002_signCoinSpends', + 'chip0002_signMessage', + 'chia_createOffer', + 'chia_takeOffer', + 'chia_cancelOffer', + 'chia_send', + 'chia_bulkMintNfts', + 'chia_signMessageByAddress', + ]; + + it.each(readOnlyCommands)( + '%s does not require confirmation', + (command) => { + expect(walletConnectCommands[command].confirm).toBe(false); + }, + ); + + it.each(confirmCommands)('%s requires confirmation', (command) => { + expect(walletConnectCommands[command].confirm).toBe(true); + }); +}); diff --git a/src/walletconnect/commands/chip0002.test.ts b/src/walletconnect/commands/chip0002.test.ts new file mode 100644 index 000000000..0e7ed74da --- /dev/null +++ b/src/walletconnect/commands/chip0002.test.ts @@ -0,0 +1,266 @@ +import { vi, describe, expect, it, beforeEach } from 'vitest'; + +vi.mock('@/bindings', () => ({ + commands: { + getNetwork: vi.fn(), + getDerivations: vi.fn(), + filterUnlockedCoins: vi.fn(), + getAssetCoins: vi.fn(), + signCoinSpends: vi.fn(), + signMessageWithPublicKey: vi.fn(), + sendTransactionImmediately: vi.fn(), + }, +})); + +import { commands } from '@/bindings'; +import { HandlerContext } from '../handler'; +import { + handleChainId, + handleConnect, + handleGetPublicKeys, + handleFilterUnlockedCoins, + handleGetAssetBalance, + handleSignCoinSpends, + handleSignMessage, + handleSendTransaction, +} from './chip0002'; + +const mockCommands = vi.mocked(commands); + +function makeContext(authResult = true): HandlerContext { + return { + promptIfEnabled: vi.fn(async () => authResult), + }; +} + +describe('handleChainId', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns network_id when present', async () => { + mockCommands.getNetwork.mockResolvedValue({ + network: { network_id: 'mainnet', name: 'Chia Mainnet' }, + } as never); + + const result = await handleChainId(); + expect(result).toBe('mainnet'); + }); + + it('falls back to name when network_id is empty', async () => { + mockCommands.getNetwork.mockResolvedValue({ + network: { network_id: '', name: 'Chia Testnet' }, + } as never); + + const result = await handleChainId(); + expect(result).toBe('Chia Testnet'); + }); +}); + +describe('handleConnect', () => { + it('always returns true', async () => { + expect(await handleConnect()).toBe(true); + }); +}); + +describe('handleGetPublicKeys', () => { + beforeEach(() => vi.clearAllMocks()); + + it('maps derivations to public keys', async () => { + mockCommands.getDerivations.mockResolvedValue({ + derivations: [ + { public_key: 'pk1', index: 0 }, + { public_key: 'pk2', index: 1 }, + ], + } as never); + + const result = await handleGetPublicKeys({ limit: 10, offset: 0 }); + expect(result).toEqual(['pk1', 'pk2']); + }); + + it('defaults limit=10 and offset=0', async () => { + mockCommands.getDerivations.mockResolvedValue({ + derivations: [], + } as never); + + await handleGetPublicKeys(undefined); + + expect(mockCommands.getDerivations).toHaveBeenCalledWith({ + limit: 10, + offset: 0, + }); + }); + + it('uses provided limit and offset', async () => { + mockCommands.getDerivations.mockResolvedValue({ + derivations: [], + } as never); + + await handleGetPublicKeys({ limit: 5, offset: 20 }); + + expect(mockCommands.getDerivations).toHaveBeenCalledWith({ + limit: 5, + offset: 20, + }); + }); +}); + +describe('handleFilterUnlockedCoins', () => { + beforeEach(() => vi.clearAllMocks()); + + it('passes coinNames as coin_ids', async () => { + mockCommands.filterUnlockedCoins.mockResolvedValue(['coin1'] as never); + + const result = await handleFilterUnlockedCoins({ + coinNames: ['coin1', 'coin2'], + }); + + expect(mockCommands.filterUnlockedCoins).toHaveBeenCalledWith({ + coin_ids: ['coin1', 'coin2'], + }); + expect(result).toEqual(['coin1']); + }); +}); + +describe('handleGetAssetBalance', () => { + beforeEach(() => vi.clearAllMocks()); + + it('accumulates balance from coin records', async () => { + mockCommands.getAssetCoins.mockResolvedValue([ + { coin: { amount: 100 }, locked: false }, + { coin: { amount: 200 }, locked: true }, + { coin: { amount: 50 }, locked: false }, + ] as never); + + const result = await handleGetAssetBalance({ + type: null, + assetId: null, + }); + + expect(result.confirmed).toBe('350'); + expect(result.spendable).toBe('150'); + expect(result.spendableCoinCount).toBe(2); + }); + + it('handles empty coin list', async () => { + mockCommands.getAssetCoins.mockResolvedValue([] as never); + + const result = await handleGetAssetBalance({ + type: 'cat', + assetId: 'abc', + }); + + expect(result.confirmed).toBe('0'); + expect(result.spendable).toBe('0'); + expect(result.spendableCoinCount).toBe(0); + }); + + it('passes includedLocked: true', async () => { + mockCommands.getAssetCoins.mockResolvedValue([] as never); + + await handleGetAssetBalance({ type: null, assetId: null }); + + expect(mockCommands.getAssetCoins).toHaveBeenCalledWith( + expect.objectContaining({ includedLocked: true }), + ); + }); +}); + +describe('handleSignCoinSpends', () => { + beforeEach(() => vi.clearAllMocks()); + + it('converts amount to string and returns aggregated signature', async () => { + mockCommands.signCoinSpends.mockResolvedValue({ + spend_bundle: { aggregated_signature: 'sig123' }, + } as never); + + const result = await handleSignCoinSpends( + { + coinSpends: [ + { + coin: { parent_coin_info: 'p', puzzle_hash: 'h', amount: 42 }, + puzzle_reveal: 'pr', + solution: 'sol', + }, + ], + }, + makeContext(), + ); + + expect(result).toBe('sig123'); + expect(mockCommands.signCoinSpends).toHaveBeenCalledWith({ + coin_spends: [ + { + coin: { parent_coin_info: 'p', puzzle_hash: 'h', amount: '42' }, + puzzle_reveal: 'pr', + solution: 'sol', + }, + ], + partial: undefined, + auto_submit: false, + }); + }); + + it('throws on biometric failure', async () => { + await expect( + handleSignCoinSpends( + { + coinSpends: [ + { + coin: { parent_coin_info: 'a', puzzle_hash: 'b', amount: 1 }, + puzzle_reveal: 'x', + solution: 'y', + }, + ], + }, + makeContext(false), + ), + ).rejects.toThrow('Authentication failed'); + }); +}); + +describe('handleSignMessage', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns signature', async () => { + mockCommands.signMessageWithPublicKey.mockResolvedValue({ + signature: 'sig456', + } as never); + + const result = await handleSignMessage( + { message: 'hello', publicKey: 'pk' }, + makeContext(), + ); + + expect(result).toBe('sig456'); + }); + + it('throws on auth failure', async () => { + await expect( + handleSignMessage( + { message: 'test', publicKey: 'pk' }, + makeContext(false), + ), + ).rejects.toThrow('Authentication failed'); + }); +}); + +describe('handleSendTransaction', () => { + beforeEach(() => vi.clearAllMocks()); + + it('sends spend bundle', async () => { + const bundle = { + coin_spends: [], + aggregated_signature: 'agg', + }; + mockCommands.sendTransactionImmediately.mockResolvedValue({ + status: 1, + error: null, + } as never); + + const result = await handleSendTransaction({ spendBundle: bundle }); + + expect(mockCommands.sendTransactionImmediately).toHaveBeenCalledWith({ + spend_bundle: bundle, + }); + expect(result).toEqual({ status: 1, error: null }); + }); +}); diff --git a/src/walletconnect/commands/high-level.test.ts b/src/walletconnect/commands/high-level.test.ts new file mode 100644 index 000000000..7b53026ae --- /dev/null +++ b/src/walletconnect/commands/high-level.test.ts @@ -0,0 +1,282 @@ +import { vi, describe, expect, it, beforeEach } from 'vitest'; + +vi.mock('@/bindings', () => ({ + commands: { + getNfts: vi.fn(), + sendCat: vi.fn(), + sendXch: vi.fn(), + signMessageByAddress: vi.fn(), + bulkMintNfts: vi.fn(), + }, +})); + +vi.mock('@/state', () => ({ + useWalletState: { + getState: vi.fn(() => ({ + sync: { receive_address: 'xch1testaddr' }, + })), + }, +})); + +import { commands } from '@/bindings'; +import { HandlerContext } from '../handler'; +import { + handleGetNfts, + handleSend, + handleGetAddress, + handleSignMessageByAddress, + handleBulkMintNfts, +} from './high-level'; + +const mockCommands = vi.mocked(commands); + +function makeContext(authResult = true): HandlerContext { + return { + promptIfEnabled: vi.fn(async () => authResult), + }; +} + +describe('handleGetNfts', () => { + beforeEach(() => vi.clearAllMocks()); + + it('maps NFT fields from snake_case to camelCase', async () => { + mockCommands.getNfts.mockResolvedValue({ + nfts: [ + { + name: 'Test NFT', + launcher_id: 'nft1', + minter_did: 'did1', + owner_did: 'did2', + collection_id: 'col1', + collection_name: 'Collection', + created_height: 100, + coin_id: 'coin1', + address: 'addr1', + royalty_address: 'raddr', + royalty_ten_thousandths: 300, + data_uris: ['https://example.com/data'], + data_hash: 'hash1', + metadata_uris: ['https://example.com/meta'], + metadata_hash: 'hash2', + license_uris: [], + license_hash: null, + edition_number: 1, + edition_total: 10, + }, + ], + } as never); + + const result = await handleGetNfts({}); + + expect(result.nfts).toHaveLength(1); + const nft = result.nfts[0]; + expect(nft.launcherId).toBe('nft1'); + expect(nft.minterDid).toBe('did1'); + expect(nft.ownerDid).toBe('did2'); + expect(nft.collectionId).toBe('col1'); + expect(nft.collectionName).toBe('Collection'); + expect(nft.createdHeight).toBe(100); + expect(nft.coinId).toBe('coin1'); + expect(nft.royaltyTenThousandths).toBe(300); + expect(nft.dataUris).toEqual(['https://example.com/data']); + }); + + it('defaults limit=10, offset=0', async () => { + mockCommands.getNfts.mockResolvedValue({ nfts: [] } as never); + + await handleGetNfts({}); + + expect(mockCommands.getNfts).toHaveBeenCalledWith( + expect.objectContaining({ limit: 10, offset: 0 }), + ); + }); + + it('passes collectionId', async () => { + mockCommands.getNfts.mockResolvedValue({ nfts: [] } as never); + + await handleGetNfts({ collectionId: 'col-abc' }); + + expect(mockCommands.getNfts).toHaveBeenCalledWith( + expect.objectContaining({ collection_id: 'col-abc' }), + ); + }); +}); + +describe('handleSend', () => { + beforeEach(() => vi.clearAllMocks()); + + it('sends XCH when no assetId', async () => { + mockCommands.sendXch.mockResolvedValue(undefined as never); + + const result = await handleSend( + { amount: 1000, address: 'xch1dest', memos: ['memo1'] }, + makeContext(), + ); + + expect(mockCommands.sendXch).toHaveBeenCalledWith({ + address: 'xch1dest', + amount: 1000, + fee: 0, + memos: ['memo1'], + auto_submit: true, + }); + expect(mockCommands.sendCat).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + + it('sends CAT when assetId is present', async () => { + mockCommands.sendCat.mockResolvedValue(undefined as never); + + await handleSend( + { assetId: 'cat-id', amount: 500, address: 'xch1dest', fee: 10 }, + makeContext(), + ); + + expect(mockCommands.sendCat).toHaveBeenCalledWith({ + asset_id: 'cat-id', + address: 'xch1dest', + amount: 500, + fee: 10, + memos: [], + auto_submit: true, + }); + expect(mockCommands.sendXch).not.toHaveBeenCalled(); + }); + + it('defaults fee to 0 and memos to empty', async () => { + mockCommands.sendXch.mockResolvedValue(undefined as never); + + await handleSend( + { amount: 1, address: 'xch1x' }, + makeContext(), + ); + + expect(mockCommands.sendXch).toHaveBeenCalledWith( + expect.objectContaining({ fee: 0, memos: [] }), + ); + }); + + it('throws on auth failure', async () => { + await expect( + handleSend({ amount: 1, address: 'xch1x' }, makeContext(false)), + ).rejects.toThrow('Authentication failed'); + }); +}); + +describe('handleGetAddress', () => { + it('returns receive_address from wallet state', async () => { + const result = await handleGetAddress(); + expect(result.address).toBe('xch1testaddr'); + }); +}); + +describe('handleSignMessageByAddress', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns signature and publicKey', async () => { + mockCommands.signMessageByAddress.mockResolvedValue({ + publicKey: 'pk1', + signature: 'sig1', + } as never); + + const result = await handleSignMessageByAddress( + { message: 'hello', address: 'xch1addr' }, + makeContext(), + ); + + expect(result).toEqual({ publicKey: 'pk1', signature: 'sig1' }); + }); + + it('throws on auth failure', async () => { + await expect( + handleSignMessageByAddress( + { message: 'x', address: 'y' }, + makeContext(false), + ), + ).rejects.toThrow('Authentication failed'); + }); +}); + +describe('handleBulkMintNfts', () => { + beforeEach(() => vi.clearAllMocks()); + + it('maps mint params and returns nft IDs', async () => { + mockCommands.bulkMintNfts.mockResolvedValue({ + nft_ids: ['nft1', 'nft2'], + } as never); + + const result = await handleBulkMintNfts( + { + did: 'did:chia:abc', + nfts: [ + { + dataUris: ['https://example.com/img.png'], + dataHash: 'hash123', + royaltyTenThousandths: 300, + }, + ], + fee: 100, + }, + makeContext(), + ); + + expect(result.nftIds).toEqual(['nft1', 'nft2']); + expect(mockCommands.bulkMintNfts).toHaveBeenCalledWith({ + did_id: 'did:chia:abc', + fee: 100, + auto_submit: true, + mints: [ + expect.objectContaining({ + data_uris: ['https://example.com/img.png'], + data_hash: 'hash123', + royalty_ten_thousandths: 300, + }), + ], + }); + }); + + it('throws when dataUris present without dataHash', async () => { + await expect( + handleBulkMintNfts( + { + did: 'did:chia:abc', + nfts: [{ dataUris: ['https://example.com/x'] }], + }, + makeContext(), + ), + ).rejects.toThrow('Data hash is required'); + }); + + it('throws when metadataUris present without metadataHash', async () => { + await expect( + handleBulkMintNfts( + { + did: 'did:chia:abc', + nfts: [{ metadataUris: ['https://example.com/meta'] }], + }, + makeContext(), + ), + ).rejects.toThrow('Metadata hash is required'); + }); + + it('throws when licenseUris present without licenseHash', async () => { + await expect( + handleBulkMintNfts( + { + did: 'did:chia:abc', + nfts: [{ licenseUris: ['https://example.com/license'] }], + }, + makeContext(), + ), + ).rejects.toThrow('License hash is required'); + }); + + it('throws on auth failure', async () => { + await expect( + handleBulkMintNfts( + { did: 'did:chia:abc', nfts: [] }, + makeContext(false), + ), + ).rejects.toThrow('Authentication failed'); + }); +}); diff --git a/src/walletconnect/commands/offers.test.ts b/src/walletconnect/commands/offers.test.ts new file mode 100644 index 000000000..fd71015de --- /dev/null +++ b/src/walletconnect/commands/offers.test.ts @@ -0,0 +1,147 @@ +import { vi, describe, expect, it, beforeEach } from 'vitest'; + +vi.mock('@/bindings', () => ({ + commands: { + makeOffer: vi.fn(), + takeOffer: vi.fn(), + cancelOffer: vi.fn(), + }, +})); + +import { commands } from '@/bindings'; +import { HandlerContext } from '../handler'; +import { handleCreateOffer, handleTakeOffer, handleCancelOffer } from './offers'; + +const mockCommands = vi.mocked(commands); + +function makeContext(authResult = true): HandlerContext { + return { + promptIfEnabled: vi.fn(async () => authResult), + }; +} + +describe('handleCreateOffer', () => { + beforeEach(() => vi.clearAllMocks()); + + it('maps empty assetId to null', async () => { + mockCommands.makeOffer.mockResolvedValue({ + offer: 'offer-blob', + offer_id: 'id-123', + } as never); + + const result = await handleCreateOffer( + { + offerAssets: [{ assetId: '', amount: 1000 }], + requestAssets: [{ assetId: 'cat1', amount: 500 }], + }, + makeContext(), + ); + + expect(mockCommands.makeOffer).toHaveBeenCalledWith( + expect.objectContaining({ + offered_assets: [ + expect.objectContaining({ asset_id: null }), + ], + requested_assets: [ + expect.objectContaining({ asset_id: 'cat1' }), + ], + }), + ); + expect(result.offer).toBe('offer-blob'); + expect(result.id).toBe('id-123'); + }); + + it('passes fee with default of 0', async () => { + mockCommands.makeOffer.mockResolvedValue({ + offer: 'x', + offer_id: 'y', + } as never); + + await handleCreateOffer( + { + offerAssets: [], + requestAssets: [], + }, + makeContext(), + ); + + expect(mockCommands.makeOffer).toHaveBeenCalledWith( + expect.objectContaining({ fee: 0 }), + ); + }); + + it('throws on biometric failure', async () => { + await expect( + handleCreateOffer( + { offerAssets: [], requestAssets: [] }, + makeContext(false), + ), + ).rejects.toThrow('Authentication failed'); + }); +}); + +describe('handleTakeOffer', () => { + beforeEach(() => vi.clearAllMocks()); + + it('passes offer and fee to takeOffer', async () => { + mockCommands.takeOffer.mockResolvedValue({ + transaction_id: 'tx-1', + } as never); + + const result = await handleTakeOffer( + { offer: 'offer-blob', fee: 50 }, + makeContext(), + ); + + expect(mockCommands.takeOffer).toHaveBeenCalledWith({ + offer: 'offer-blob', + fee: 50, + auto_submit: true, + }); + expect(result.id).toBe('tx-1'); + }); + + it('defaults fee to 0', async () => { + mockCommands.takeOffer.mockResolvedValue({ + transaction_id: 'tx-2', + } as never); + + await handleTakeOffer({ offer: 'x' }, makeContext()); + + expect(mockCommands.takeOffer).toHaveBeenCalledWith( + expect.objectContaining({ fee: 0 }), + ); + }); + + it('throws on auth failure', async () => { + await expect( + handleTakeOffer({ offer: 'x' }, makeContext(false)), + ).rejects.toThrow('Authentication failed'); + }); +}); + +describe('handleCancelOffer', () => { + beforeEach(() => vi.clearAllMocks()); + + it('passes offer id and fee', async () => { + mockCommands.cancelOffer.mockResolvedValue(undefined as never); + + const result = await handleCancelOffer( + { id: 'offer-456', fee: 10 }, + makeContext(), + ); + + expect(mockCommands.cancelOffer).toHaveBeenCalledWith({ + offer_id: 'offer-456', + fee: 10, + auto_submit: true, + }); + expect(result).toEqual({}); + }); + + it('throws on auth failure', async () => { + await expect( + handleCancelOffer({ id: 'x' }, makeContext(false)), + ).rejects.toThrow('Authentication failed'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..bb4da77dd --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,24 @@ +import { lingui } from '@lingui/vite-plugin'; +import react from '@vitejs/plugin-react-swc'; +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [ + react({ + plugins: [['@lingui/swc-plugin', {}]], + }), + lingui(), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['src/__tests__/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + }, +});