From e15ddab54283cd201f6b6fc31d226a11f8699147 Mon Sep 17 00:00:00 2001 From: wonfen Date: Wed, 19 Aug 2026 10:24:37 +0800 Subject: [PATCH] test: trim redundant test coverage --- crates/clash-verge-draft/tests/test_me.rs | 516 ++------ crates/clash-verge-i18n/src/lib.rs | 17 - crates/clash-verge-limiter/src/lib.rs | 104 +- crates/clash-verge-media-unlock/src/utils.rs | 34 - package.json | 3 - pnpm-lock.yaml | 6 - pnpm-workspace.yaml | 1 - scripts/dev-control.test.mjs | 1167 ----------------- src-tauri/src/cmd/mod.rs | 142 +- src-tauri/src/cmd/verge.rs | 28 - src-tauri/src/config/config.rs | 39 +- src-tauri/src/config/mixed_port.rs | 27 +- src-tauri/src/config/prfitem.rs | 16 - src-tauri/src/core/logger.rs | 13 - src-tauri/src/core/notification.rs | 148 --- src-tauri/src/core/updater.rs | 70 - src-tauri/src/feat/icon.rs | 100 -- src-tauri/src/feat/proxy.rs | 12 - src-tauri/src/utils/notification.rs | 16 - src-tauri/src/utils/resolve/window.rs | 23 - src-tauri/src/utils/singleton.rs | 25 - src-tauri/src/utils/speed.rs | 28 - src-tauri/src/utils/startup.rs | 20 - src/components/layout/notice-manager.test.tsx | 173 --- .../layout/sysproxy-privilege-dialog.test.tsx | 350 ----- .../proxy/proxy-empty-state-model.test.ts | 120 -- src/components/proxy/use-render-list.test.ts | 55 - .../shared/proxy-control-switches.test.tsx | 145 -- src/hooks/use-record-selection.test.tsx | 98 -- src/hooks/use-system-proxy-state.test.tsx | 147 --- src/hooks/use-update.test.tsx | 32 - .../hooks/use-pending-failures.test.tsx | 228 ---- src/providers/app-data-provider.test.tsx | 56 - src/services/delay.test.ts | 81 +- src/services/events.test.ts | 72 +- src/services/notice-service.test.ts | 241 ---- src/utils/delay.test.ts | 93 +- vitest.config.mts | 7 +- 38 files changed, 194 insertions(+), 4259 deletions(-) delete mode 100644 scripts/dev-control.test.mjs delete mode 100644 src/components/layout/notice-manager.test.tsx delete mode 100644 src/components/layout/sysproxy-privilege-dialog.test.tsx delete mode 100644 src/components/proxy/proxy-empty-state-model.test.ts delete mode 100644 src/components/proxy/use-render-list.test.ts delete mode 100644 src/components/shared/proxy-control-switches.test.tsx delete mode 100644 src/hooks/use-record-selection.test.tsx delete mode 100644 src/hooks/use-system-proxy-state.test.tsx delete mode 100644 src/hooks/use-update.test.tsx delete mode 100644 src/pages/_layout/hooks/use-pending-failures.test.tsx delete mode 100644 src/providers/app-data-provider.test.tsx delete mode 100644 src/services/notice-service.test.ts diff --git a/crates/clash-verge-draft/tests/test_me.rs b/crates/clash-verge-draft/tests/test_me.rs index c4df846ca..af0e403ec 100644 --- a/crates/clash-verge-draft/tests/test_me.rs +++ b/crates/clash-verge-draft/tests/test_me.rs @@ -1,447 +1,125 @@ -#[cfg(test)] -mod tests { - use anyhow::anyhow; - use clash_verge_draft::Draft; - use std::future::Future; - use std::sync::Arc; - use std::time::Duration; - use tokio::sync::Notify; - use tokio::time::sleep; +use std::sync::Arc; - #[derive(Clone, Debug, Default, PartialEq)] - struct IVerge { - enable_auto_launch: Option, - enable_tun_mode: Option, - } +use clash_verge_draft::{Draft, DraftTransaction}; +use tokio::sync::Notify; - const STRESS_LEVELS: [usize; 6] = [8, 16, 32, 64, 128, 256]; +#[derive(Clone, Debug, Default, PartialEq)] +struct Config { + enabled: bool, + port: u16, +} - fn verge(enable_auto_launch: bool, enable_tun_mode: bool) -> IVerge { - IVerge { - enable_auto_launch: Some(enable_auto_launch), - enable_tun_mode: Some(enable_tun_mode), - } - } +#[test] +fn edits_can_be_committed_or_discarded() { + let draft = Draft::new(Config { + enabled: false, + port: 7897, + }); - fn block_on_ready(fut: F) -> F::Output { - tokio::runtime::Runtime::new().unwrap().block_on(fut) - } + draft.edit_draft(|config| config.enabled = true); + assert!(!draft.data_arc().enabled); + assert!(draft.latest_arc().enabled); + draft.apply(); + assert!(draft.data_arc().enabled); - #[test] - fn test_draft_basic_flow() { - let draft = Draft::new(verge(true, false)); + draft.edit_draft(|config| config.port = 9090); + draft.discard(); + assert_eq!(draft.latest_arc().port, 7897); +} - { - let data = draft.data_arc(); - assert_eq!(data.enable_auto_launch, Some(true)); - assert_eq!(data.enable_tun_mode, Some(false)); - } +#[tokio::test] +async fn async_updates_are_serialized() -> anyhow::Result<()> { + let draft = Arc::new(Draft::new(0_u8)); + let mut updates = Vec::new(); - draft.edit_draft(|d| { - d.enable_auto_launch = Some(false); - d.enable_tun_mode = Some(true); - }); - - { - let data = draft.data_arc(); - assert_eq!(data.enable_auto_launch, Some(true)); - assert_eq!(data.enable_tun_mode, Some(false)); - } - - { - let latest = draft.latest_arc(); - assert_eq!(latest.enable_auto_launch, Some(false)); - assert_eq!(latest.enable_tun_mode, Some(true)); - } - - draft.apply(); - - { - let data = draft.data_arc(); - assert_eq!(data.enable_auto_launch, Some(false)); - assert_eq!(data.enable_tun_mode, Some(true)); - } - - // 新一轮草稿并修改 - draft.edit_draft(|d| { - d.enable_auto_launch = Some(true); - }); - { - let latest = draft.latest_arc(); - assert_eq!(latest.enable_auto_launch, Some(true)); - assert_eq!(latest.enable_tun_mode, Some(true)); - } - - draft.discard(); - - { - draft.edit_draft(|d| { - assert_eq!(d.enable_auto_launch, Some(false)); - d.enable_tun_mode = Some(false); - }); - let data = draft.data_arc(); - assert_eq!(data.enable_auto_launch, Some(false)); - assert_eq!(data.enable_tun_mode, Some(true)); - } - } - - #[test] - fn test_arc_pointer_behavior_on_edit_and_apply() { - let draft = Draft::new(verge(true, false)); - - let committed = draft.data_arc(); - let latest = draft.latest_arc(); - assert!(Arc::ptr_eq(&committed, &latest)); - - draft.edit_draft(|d| d.enable_tun_mode = Some(true)); - let committed_after_first_edit = draft.data_arc(); - let draft_after_first_edit = draft.latest_arc(); - assert!(!Arc::ptr_eq(&committed_after_first_edit, &draft_after_first_edit)); - let prev_draft_ptr = Arc::as_ptr(&draft_after_first_edit); - draft.apply(); - let committed_after_apply = draft.data_arc(); - assert_eq!(Arc::as_ptr(&committed_after_apply), prev_draft_ptr); - - draft.edit_draft(|d| d.enable_auto_launch = Some(false)); - let latest1 = draft.latest_arc(); - let latest1_ptr = Arc::as_ptr(&latest1); - drop(latest1); - - draft.edit_draft(|d| d.enable_tun_mode = Some(false)); - let latest2 = draft.latest_arc(); - let latest2_ptr = Arc::as_ptr(&latest2); - - assert_eq!(latest1_ptr, latest2_ptr, "Unique edit should not clone Arc"); - assert_eq!(latest2.enable_auto_launch, Some(false)); - assert_eq!(latest2.enable_tun_mode, Some(false)); - } - - #[test] - fn test_discard_restores_latest_to_committed() { - let draft = Draft::new(verge(false, false)); - - draft.edit_draft(|d| d.enable_auto_launch = Some(true)); - let committed = draft.data_arc(); - let latest = draft.latest_arc(); - assert!(!Arc::ptr_eq(&committed, &latest)); - - draft.discard(); - let committed2 = draft.data_arc(); - let latest2 = draft.latest_arc(); - assert!(Arc::ptr_eq(&committed2, &latest2)); - assert_eq!(latest2.enable_auto_launch, Some(false)); - } - - #[test] - fn test_edit_draft_returns_closure_result() { - let draft = Draft::new(IVerge::default()); - let ret = draft.edit_draft(|d| { - d.enable_tun_mode = Some(true); - 123usize - }); - assert_eq!(ret, 123); - let latest = draft.latest_arc(); - assert_eq!(latest.enable_tun_mode, Some(true)); - } - - #[test] - fn test_with_data_modify_ok_and_replaces_committed() { - let draft = Draft::new(verge(false, false)); - - let res = block_on_ready(draft.with_data_modify(|mut v| async move { - v.enable_auto_launch = Some(true); - Ok((v, "done")) + for _ in 0..16 { + let draft = Arc::clone(&draft); + updates.push(tokio::spawn(async move { + draft.with_data_modify(|value| async move { Ok((value + 1, ())) }).await })); - assert_eq!(res.unwrap(), "done"); - - let committed = draft.data_arc(); - assert_eq!(committed.enable_auto_launch, Some(true)); - assert_eq!(committed.enable_tun_mode, Some(false)); + } + for update in updates { + update.await??; } - #[test] - fn test_with_data_modify_keeps_boxed_data_shape() { - let draft = Draft::new(Box::new(verge(false, false))); + assert_eq!(**draft.data_arc(), 16); + Ok(()) +} - block_on_ready(draft.with_data_modify(|mut v| async move { - v.enable_auto_launch = Some(true); - Ok((v, ())) - })) - .unwrap(); - - assert_eq!(draft.data_arc().enable_auto_launch, Some(true)); - } - - #[test] - fn test_with_data_modify_error_releases_permit() { - let draft = Draft::new(IVerge::default()); - - #[allow(clippy::unwrap_used)] - let err = block_on_ready(draft.with_data_modify(|_v| async move { Err::<(IVerge, ()), _>(anyhow!("boom")) })) - .unwrap_err(); - - assert_eq!(format!("{err}"), "boom"); - - #[allow(clippy::unwrap_used)] - block_on_ready(draft.with_data_modify(|mut v| async move { - v.enable_auto_launch = Some(true); - Ok((v, ())) - })) - .unwrap(); - - assert_eq!(draft.data_arc().enable_auto_launch, Some(true)); - } - - #[test] - fn test_with_data_modify_serialized_under_stress_levels() { - for task_count in STRESS_LEVELS { - run_with_data_modify_stress(task_count, Duration::from_millis(1)); - } - } - - #[test] - fn test_with_data_modify_keeps_apply_conflict_detection() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let draft = Arc::new(Draft::new(verge(false, false))); - let started = Arc::new(Notify::new()); - let finish = Arc::new(Notify::new()); - - rt.block_on(async { - let task = { - let draft = Arc::clone(&draft); - let started = Arc::clone(&started); - let finish = Arc::clone(&finish); - tokio::spawn(async move { - draft - .with_data_modify(|mut v| async move { - started.notify_one(); - finish.notified().await; - v.enable_auto_launch = Some(true); - Ok((v, ())) - }) - .await +#[tokio::test] +async fn async_update_rejects_a_changed_committed_value() -> anyhow::Result<()> { + let draft = Arc::new(Draft::new(Config::default())); + let started = Arc::new(Notify::new()); + let finish = Arc::new(Notify::new()); + let update = { + let draft = Arc::clone(&draft); + let started = Arc::clone(&started); + let finish = Arc::clone(&finish); + tokio::spawn(async move { + draft + .with_data_modify(|mut config| async move { + started.notify_one(); + finish.notified().await; + config.enabled = true; + Ok((config, ())) }) - }; - - started.notified().await; - draft.edit_draft(|d| d.enable_tun_mode = Some(true)); - draft.apply(); - finish.notify_one(); - - let err = task.await.expect("task panicked").unwrap_err(); - assert_eq!( - format!("{err}"), - "Optimistic lock failed: Committed data has changed during async operation" - ); - }); - - let committed = draft.data_arc(); - assert_eq!(committed.enable_auto_launch, Some(false)); - assert_eq!(committed.enable_tun_mode, Some(true)); - } - - fn run_with_data_modify_stress(task_count: usize, delay: Duration) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let draft = Arc::new(Draft::new(verge(false, false))); - - rt.block_on(async { - let mut handles = Vec::with_capacity(task_count); - for _ in 0..task_count { - let draft = Arc::clone(&draft); - handles.push(tokio::spawn(async move { - draft - .with_data_modify(|mut v| async move { - if !delay.is_zero() { - sleep(delay).await; - } - v.enable_auto_launch = Some(!v.enable_auto_launch.unwrap_or(false)); - Ok((v, ())) - }) - .await - })); - } - - for handle in handles { - handle.await.expect("task panicked").expect("with_data_modify failed"); - } - - Ok::<(), anyhow::Error>(()) + .await }) - .unwrap(); - } + }; - #[test] - fn test_with_data_modify_does_not_touch_existing_draft() { - let draft = Draft::new(verge(false, false)); + started.notified().await; + draft.edit_draft(|config| config.port = 7897); + draft.apply(); + finish.notify_one(); - draft.edit_draft(|d| { - d.enable_auto_launch = Some(true); - d.enable_tun_mode = Some(true); - }); - let draft_before = draft.latest_arc(); - let draft_before_ptr = Arc::as_ptr(&draft_before); - - #[allow(clippy::unwrap_used)] - block_on_ready(draft.with_data_modify(|mut v| async move { - v.enable_auto_launch = Some(false); - Ok((v, ())) - })) - .unwrap(); - - let draft_after = draft.latest_arc(); - assert_eq!( - Arc::as_ptr(&draft_after), - draft_before_ptr, - "Existing draft should not be replaced by with_data_modify" - ); - assert_eq!(draft_after.enable_auto_launch, Some(true)); - assert_eq!(draft_after.enable_tun_mode, Some(true)); - - draft.discard(); - let latest = draft.latest_arc(); - assert_eq!(latest.enable_auto_launch, Some(false)); - assert_eq!(latest.enable_tun_mode, Some(false)); - } + assert!(update.await?.is_err()); + assert!(!draft.data_arc().enabled); + assert_eq!(draft.data_arc().port, 7897); + Ok(()) } -mod transactions { - use clash_verge_draft::{Draft, DraftTransaction}; +#[test] +fn transaction_commits_every_layer() -> anyhow::Result<()> { + let first = Draft::new(1_u8); + let second = Draft::new(String::from("a")); + let transaction = DraftTransaction::begin(vec![&first, &second])?; - fn begin<'a>(layers: Vec<&'a dyn clash_verge_draft::DraftLayer>) -> DraftTransaction<'a> { - DraftTransaction::begin(layers).expect("no other transaction should hold these layers") - } + first.edit_draft(|value| *value = 2); + second.edit_draft(|value| *value = String::from("b")); + transaction.commit(); - #[test] - fn committing_applies_every_layer() { - let first = Draft::new(1_u8); - let second = Draft::new("a".to_owned()); + assert_eq!(**first.data_arc(), 2); + assert_eq!(**second.data_arc(), "b"); + Ok(()) +} - let transaction = begin(vec![&first, &second]); +#[test] +fn dropped_transaction_rolls_every_layer_back() -> anyhow::Result<()> { + let first = Draft::new(1_u8); + let second = Draft::new(String::from("a")); + + { + let _transaction = DraftTransaction::begin(vec![&first, &second])?; first.edit_draft(|value| *value = 2); - second.edit_draft(|value| *value = "b".to_owned()); - transaction.commit(); - - assert_eq!(**first.data_arc(), 2); - assert_eq!(**second.data_arc(), "b"); + second.edit_draft(|value| *value = String::from("b")); } - #[test] - fn dropping_without_committing_rolls_every_layer_back() { - let first = Draft::new(1_u8); - let second = Draft::new("a".to_owned()); - - { - let _transaction = begin(vec![&first, &second]); - first.edit_draft(|value| *value = 2); - second.edit_draft(|value| *value = "b".to_owned()); - } - - assert_eq!(**first.data_arc(), 1); - assert_eq!(**second.data_arc(), "a"); - assert_eq!( - **first.latest_arc(), - 1, - "the draft itself is gone, not just uncommitted" - ); - } - - #[test] - fn an_early_return_rolls_back_without_the_caller_saying_so() { - fn attempt(draft: &Draft, succeed: bool) -> Result<(), &'static str> { - let transaction = begin(vec![draft]); - draft.edit_draft(|value| *value = 9); - if !succeed { - return Err("failed after editing"); - } - transaction.commit(); - Ok(()) - } - - let draft = Draft::new(1_u8); - assert!(attempt(&draft, false).is_err()); - assert_eq!(**draft.data_arc(), 1, "a failed attempt leaves nothing behind"); - - assert!(attempt(&draft, true).is_ok()); - assert_eq!(**draft.data_arc(), 9); - } - - #[test] - fn a_partly_edited_transaction_still_rolls_back_whole() { - // The layer nobody edited must not be left holding a stale draft either. - let edited = Draft::new(1_u8); - let untouched = Draft::new(5_u8); - - drop(begin(vec![&edited, &untouched])); - edited.edit_draft(|value| *value = 2); - - { - let _transaction = begin(vec![&edited, &untouched]); - } - - assert_eq!(**edited.latest_arc(), 1, "the earlier stray draft is cleared too"); - assert_eq!(**untouched.latest_arc(), 5); - } + assert_eq!(**first.latest_arc(), 1); + assert_eq!(**second.latest_arc(), "a"); + Ok(()) } -mod transaction_exclusivity { - use clash_verge_draft::{Draft, DraftTransaction}; +#[test] +fn transaction_claims_are_exclusive_and_release_partial_claims() -> anyhow::Result<()> { + let first = Draft::new(1_u8); + let second = Draft::new(2_u8); + let held = DraftTransaction::begin(vec![&second])?; - #[test] - fn a_second_transaction_over_a_claimed_layer_is_refused() { - let draft = Draft::new(1_u8); - let _held = DraftTransaction::begin(vec![&draft]).expect("the first claim should succeed"); + assert!(DraftTransaction::begin(vec![&first, &second]).is_err()); + let available = DraftTransaction::begin(vec![&first])?; - let refused = DraftTransaction::begin(vec![&draft]); - - assert!(refused.is_err(), "a layer may only be staged by one transaction"); - } - - #[test] - fn a_refused_transaction_leaves_the_layers_it_did_claim_free() { - // Claiming is all-or-nothing: a caller that failed on the third layer must not keep - // the first two, or the next attempt would be refused for a transaction that no - // longer exists. - let first = Draft::new(1_u8); - let second = Draft::new(2_u8); - let _contended = DraftTransaction::begin(vec![&second]).expect("the first claim should succeed"); - - assert!(DraftTransaction::begin(vec![&first, &second]).is_err()); - - drop(DraftTransaction::begin(vec![&first]).expect("the layer that was rolled off must be free")); - } - - #[test] - fn a_layer_is_free_again_once_its_transaction_ends() { - let draft = Draft::new(1_u8); - - drop(DraftTransaction::begin(vec![&draft]).expect("first")); - let committed = DraftTransaction::begin(vec![&draft]).expect("free after a rollback"); - committed.commit(); - - drop(DraftTransaction::begin(vec![&draft]).expect("free after a commit")); - } - - #[test] - fn an_overlapping_writer_can_no_longer_discard_a_staged_edit() { - // The regression this pins. A holds a transaction across a slow side effect; B starts, - // stages its own edit over the one draft slot, fails, and rolls back. Before layers - // were claimed, B's rollback cleared the single draft, A's commit then found nothing to - // apply and did nothing at all — while A's caller was told it had succeeded and A's own - // side effect had already happened. - let verge = Draft::new(String::from("committed")); - - let transaction_a = DraftTransaction::begin(vec![&verge]).expect("A claims the layer"); - verge.edit_draft(|value| *value = String::from("A-wants-this")); - - let transaction_b = DraftTransaction::begin(vec![&verge]); - assert!(transaction_b.is_err(), "B must be refused rather than share the slot"); - - transaction_a.commit(); - - assert_eq!( - **verge.data_arc(), - "A-wants-this", - "A's edit must survive an overlapping writer" - ); - } + drop(available); + drop(held); + assert!(DraftTransaction::begin(vec![&second]).is_ok()); + Ok(()) } diff --git a/crates/clash-verge-i18n/src/lib.rs b/crates/clash-verge-i18n/src/lib.rs index a59027e94..6989dfb7d 100644 --- a/crates/clash-verge-i18n/src/lib.rs +++ b/crates/clash-verge-i18n/src/lib.rs @@ -85,20 +85,3 @@ macro_rules! t { } }; } - -#[cfg(test)] -mod test { - use super::resolve_supported_language; - - #[test] - fn test_resolve_supported_language() { - assert_eq!(resolve_supported_language("en").as_deref(), Some("en")); - assert_eq!(resolve_supported_language("en-US").as_deref(), Some("en")); - assert_eq!(resolve_supported_language("zh").as_deref(), Some("zh")); - assert_eq!(resolve_supported_language("zh-CN").as_deref(), Some("zh")); - assert_eq!(resolve_supported_language("zh-Hant").as_deref(), Some("zhtw")); - assert_eq!(resolve_supported_language("jp").as_deref(), Some("jp")); - assert_eq!(resolve_supported_language("ja-JP").as_deref(), Some("jp")); - assert_eq!(resolve_supported_language("fr"), None); - } -} diff --git a/crates/clash-verge-limiter/src/lib.rs b/crates/clash-verge-limiter/src/lib.rs index 263771f08..7af97fc07 100644 --- a/crates/clash-verge-limiter/src/lib.rs +++ b/crates/clash-verge-limiter/src/lib.rs @@ -61,12 +61,13 @@ impl Limiter { } #[cfg(test)] -mod extra_tests { +mod tests { + use std::sync::atomic::AtomicU64; + use super::*; - use std::sync::Arc; - use std::thread; struct MockClock(AtomicU64); + impl Clock for MockClock { fn now_ms(&self) -> u64 { self.0.load(Ordering::SeqCst) @@ -74,92 +75,35 @@ mod extra_tests { } #[test] - fn test_zero_period_always_passes() { - let mock = MockClock(AtomicU64::new(100)); - let limiter = Limiter::new(Duration::from_millis(0), &mock); + fn enforces_the_period_boundary() { + let clock = MockClock(AtomicU64::new(1_000)); + let limiter = Limiter::new(Duration::from_millis(100), &clock); assert!(limiter.check()); - assert!(limiter.check()); - } - - #[test] - fn test_boundary_condition() { - let period_ms = 100; - let mock = MockClock(AtomicU64::new(1000)); - let limiter = Limiter::new(Duration::from_millis(period_ms), &mock); - - assert!(limiter.check()); - - mock.0.store(1099, Ordering::SeqCst); + clock.0.store(1_099, Ordering::SeqCst); assert!(!limiter.check()); - - mock.0.store(1100, Ordering::SeqCst); - assert!(limiter.check(), "Should pass exactly at period boundary"); + clock.0.store(1_100, Ordering::SeqCst); + assert!(limiter.check()); } #[test] - fn test_high_concurrency_consistency() { - let period = Duration::from_millis(1000); - let mock = Arc::new(MockClock(AtomicU64::new(1000))); - let limiter = Arc::new(Limiter::new(period, Arc::clone(&mock))); - + fn concurrent_checks_admit_one_caller() { + let clock = Arc::new(MockClock(AtomicU64::new(1_000))); + let limiter = Arc::new(Limiter::new(Duration::from_millis(100), Arc::clone(&clock))); assert!(limiter.check()); + clock.0.store(1_100, Ordering::SeqCst); - mock.0.store(2500, Ordering::SeqCst); - - let mut handles = vec![]; - for _ in 0..20 { - let l = Arc::clone(&limiter); - handles.push(thread::spawn(move || l.check())); + let mut handles = Vec::with_capacity(8); + for _ in 0..8 { + let limiter = Arc::clone(&limiter); + handles.push(std::thread::spawn(move || limiter.check())); } + let admitted = handles + .into_iter() + .map(|handle| handle.join().unwrap_or(false)) + .filter(|passed| *passed) + .count(); - #[allow(clippy::unwrap_used)] - let results: Vec = handles.into_iter().map(|h| h.join().unwrap()).collect(); - - let success_count = results.iter().filter(|&&x| x).count(); - assert_eq!(success_count, 1); - - assert_eq!(limiter.last_run_ms.load(Ordering::SeqCst), 2500); - } - - #[test] - fn test_extreme_time_jump() { - let mock = MockClock(AtomicU64::new(100)); - let limiter = Limiter::new(Duration::from_millis(100), &mock); - - assert!(limiter.check()); - - mock.0.store(u64::MAX - 10, Ordering::SeqCst); - assert!(limiter.check()); - } - - #[test] - fn test_system_clock_real_path() { - let clock = SystemClock; - let start = clock.now_ms(); - assert!(start > 0); - - std::thread::sleep(Duration::from_millis(10)); - assert!(clock.now_ms() >= start); - } - - #[test] - fn test_limiter_with_system_clock_default() { - let limiter = Limiter::new(Duration::from_millis(100), SystemClock); - assert!(limiter.check()); - } - - #[test] - fn test_coverage_time_backward() { - let mock = MockClock(AtomicU64::new(5000)); - let limiter = Limiter::new(Duration::from_millis(100), &mock); - - assert!(limiter.check()); - - mock.0.store(4000, Ordering::SeqCst); - - assert!(limiter.check(), "Should pass and reset when time moves backward"); - - assert_eq!(limiter.last_run_ms.load(Ordering::SeqCst), 4000); + assert_eq!(admitted, 1); } } diff --git a/crates/clash-verge-media-unlock/src/utils.rs b/crates/clash-verge-media-unlock/src/utils.rs index 9bc003509..91ff73ffa 100644 --- a/crates/clash-verge-media-unlock/src/utils.rs +++ b/crates/clash-verge-media-unlock/src/utils.rs @@ -37,37 +37,3 @@ fn alpha2_to_emoji(alpha2: &str) -> String { .and_then(|x| char::from_u32(c2).map(|y| format!("{x}{y}"))) .unwrap_or_default() } - -#[cfg(test)] -mod tests { - use super::country_code_to_emoji; - - #[test] - fn country_code_to_emoji_iso2() { - assert_eq!(country_code_to_emoji("CN"), "🇨🇳"); - assert_eq!(country_code_to_emoji("us"), "🇺🇸"); - } - - #[test] - fn country_code_to_emoji_iso3() { - assert_eq!(country_code_to_emoji("CHN"), "🇨🇳"); - assert_eq!(country_code_to_emoji("USA"), "🇺🇸"); - } - - #[test] - fn country_code_to_emoji_invalid() { - assert_eq!(country_code_to_emoji("XXX"), ""); - assert_eq!(country_code_to_emoji("ZZ"), ""); - } - - #[test] - fn country_code_to_emoji_short() { - assert_eq!(country_code_to_emoji("C"), ""); - assert_eq!(country_code_to_emoji(""), ""); - } - - #[test] - fn country_code_to_emoji_long() { - assert_eq!(country_code_to_emoji("CNAAA"), ""); - } -} diff --git a/package.json b/package.json index a9cf49ffe..e269d1216 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "test:dev-control": "node --test scripts/dev-control.test.mjs", "knip:check": "knip", "knip:report": "knip --no-exit-code" }, @@ -91,7 +90,6 @@ "@eslint-react/eslint-plugin": "^5.8.5", "@eslint/js": "^10.0.1", "@tauri-apps/cli": "2.11.4", - "@testing-library/react": "^16.3.2", "@types/lodash-es": "^4.17.12", "@types/node": "^24.12.4", "@types/react": "19.2.18", @@ -118,7 +116,6 @@ "https-proxy-agent": "^9.0.0", "husky": "^9.1.7", "jiti": "^2.7.0", - "jsdom": "^30.0.0", "knip": "6.32.1", "lint-staged": "^17.0.5", "sass": "^1.100.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 896db9e62..7ca6abd38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,9 +150,6 @@ importers: '@tauri-apps/cli': specifier: 2.11.4 version: 2.11.4 - '@testing-library/react': - specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -231,9 +228,6 @@ importers: jiti: specifier: ^2.7.0 version: 2.7.0 - jsdom: - specifier: ^30.0.0 - version: 30.0.1 knip: specifier: 6.32.1 version: 6.32.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e8f37dd6b..cfd7dad1e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,4 +7,3 @@ allowBuilds: unrs-resolver: true minimumReleaseAgeExclude: - dayjs@1.11.21 - - jsdom@30.0.0 diff --git a/scripts/dev-control.test.mjs b/scripts/dev-control.test.mjs deleted file mode 100644 index 353ab9a6f..000000000 --- a/scripts/dev-control.test.mjs +++ /dev/null @@ -1,1167 +0,0 @@ -import assert from 'node:assert/strict' -import { EventEmitter } from 'node:events' -import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import test from 'node:test' -import { inspect } from 'node:util' - -import { - createMacosShutdownController, - readPrivateInstanceRecord, - requestDevQuit, - waitForPortClosed, -} from './dev-control.mjs' -import { windowsElevationInvocation } from './dev-service.mjs' -import { - buildTauriInvocation, - mirrorChildExit, - prepareDevRuntime, - runDevAnchor, - runDevCommand, - terminateDetachedGroup, -} from './dev.mjs' - -const token = 'ab'.repeat(32) -const posixOnly = { - skip: process.platform === 'win32' ? 'requires POSIX file metadata' : false, -} - -test('development startup preserves the installed service state unless service mode is explicit', async () => { - for (const mode of ['dev', 'trace', 'sidecar', 'service']) { - const calls = [] - const result = await prepareDevRuntime(mode, { - ensureService: async () => { - calls.push('ensure') - return '/service' - }, - prepareService: async () => { - calls.push('prepare') - return '/service' - }, - }) - - assert.deepEqual(calls, [mode === 'service' ? 'ensure' : 'prepare']) - assert.equal(result.mode, mode === 'service' ? 'dev' : mode) - assert.equal(result.serviceDirectory, '/service') - } -}) - -test('Windows elevation passes the installer path outside PowerShell source', () => { - const installer = String.raw`C:\ssp\path with spaces\clash-verge-service-install.exe` - const baseEnvironment = { SYSTEMROOT: String.raw`C:\Windows` } - - const invocation = windowsElevationInvocation(installer, baseEnvironment) - - assert.equal(invocation.command, 'powershell.exe') - assert.equal(invocation.args.at(-2), '-Command') - assert.equal(invocation.args.includes(installer), false) - assert.equal(invocation.env.CLASH_VERGE_DEV_SERVICE_INSTALLER, installer) - assert.equal(baseEnvironment.CLASH_VERGE_DEV_SERVICE_INSTALLER, undefined) -}) - -async function withTemporaryDirectory(run) { - const root = await mkdtemp(join(tmpdir(), 'clash-verge-dev-control-')) - try { - await run(root) - } finally { - await rm(root, { recursive: true, force: true }) - } -} - -function requestSequence(statuses, requests) { - return (options, onResponse) => { - const outgoing = new EventEmitter() - outgoing.setTimeout = () => outgoing - outgoing.destroy = (error) => - queueMicrotask(() => outgoing.emit('error', error)) - outgoing.end = () => { - requests.push(options) - const outcome = statuses.shift() - if (outcome instanceof Error) { - queueMicrotask(() => outgoing.emit('error', outcome)) - return - } - const response = new EventEmitter() - response.statusCode = outcome - response.resume = () => {} - response.destroy = () => {} - queueMicrotask(() => { - onResponse(response) - queueMicrotask(() => response.emit('end')) - }) - } - return outgoing - } -} - -function responseLifecycleRequest(status, events) { - return (_options, onResponse) => { - const outgoing = new EventEmitter() - outgoing.setTimeout = () => outgoing - outgoing.destroy = (error) => - queueMicrotask(() => outgoing.emit('error', error)) - outgoing.end = () => { - const response = new EventEmitter() - response.statusCode = status - response.resume = () => {} - response.destroy = () => {} - queueMicrotask(() => { - onResponse(response) - for (const event of events) { - response.emit( - event, - event === 'error' ? new Error('response failed') : undefined, - ) - } - }) - } - return outgoing - } -} - -function releasedResponseSequence(statuses, releases) { - return (_options, onResponse) => { - const outgoing = new EventEmitter() - outgoing.setTimeout = () => outgoing - outgoing.destroy = (error) => - queueMicrotask(() => outgoing.emit('error', error)) - outgoing.end = () => { - const response = new EventEmitter() - response.statusCode = statuses.shift() - response.resume = () => {} - response.destroy = () => { - releases.push(response.statusCode) - response.emit('aborted') - response.emit('error', new Error('destroyed response')) - response.emit('close') - } - queueMicrotask(() => onResponse(response)) - } - return outgoing - } -} - -function createChild(pid) { - const child = new EventEmitter() - child.pid = pid - return child -} - -function flushTasks() { - return new Promise((resolve) => setImmediate(resolve)) -} - -test( - 'private instance record must be an owned 0600 ordinary file', - posixOnly, - async () => { - await withTemporaryDirectory(async (root) => { - const recordPath = join(root, 'singleton-instance.json') - await writeFile(recordPath, JSON.stringify({ port: 42123, token }), { - mode: 0o600, - }) - assert.deepEqual( - await readPrivateInstanceRecord(recordPath, process.getuid()), - { - port: 42123, - token, - }, - ) - - await chmod(recordPath, 0o644) - await assert.rejects( - readPrivateInstanceRecord(recordPath, process.getuid()), - /0600/, - ) - }) - }, -) - -test('private instance record rejects a symbolic link', posixOnly, async () => { - await withTemporaryDirectory(async (root) => { - const targetPath = join(root, 'target.json') - const recordPath = join(root, 'singleton-instance.json') - await writeFile(targetPath, JSON.stringify({ port: 42123, token }), { - mode: 0o600, - }) - await symlink(targetPath, recordPath) - - await assert.rejects( - readPrivateInstanceRecord(recordPath, process.getuid()), - ) - }) -}) - -test('private instance record rejects the wrong owner', posixOnly, async () => { - await withTemporaryDirectory(async (root) => { - const recordPath = join(root, 'singleton-instance.json') - await writeFile(recordPath, JSON.stringify({ port: 42123, token }), { - mode: 0o600, - }) - - await assert.rejects( - readPrivateInstanceRecord(recordPath, process.getuid() + 1), - /owner/, - ) - }) -}) - -test('private instance record rejects invalid ports', posixOnly, async (t) => { - for (const port of [0, 65536, 1.5, '42123']) { - await t.test(String(port), async () => { - await withTemporaryDirectory(async (root) => { - const recordPath = join(root, 'singleton-instance.json') - await writeFile(recordPath, JSON.stringify({ port, token }), { - mode: 0o600, - }) - await assert.rejects( - readPrivateInstanceRecord(recordPath, process.getuid()), - /port/, - ) - }) - }) - } -}) - -test( - 'private instance record rejects tokens outside the lowercase 64-character format', - posixOnly, - async (t) => { - for (const invalidToken of [ - 'ab'.repeat(31), - 'AB'.repeat(32), - 'z'.repeat(64), - ]) { - await t.test( - invalidToken.length === 64 ? invalidToken.slice(0, 2) : 'short', - async () => { - await withTemporaryDirectory(async (root) => { - const recordPath = join(root, 'singleton-instance.json') - await writeFile( - recordPath, - JSON.stringify({ port: 42123, token: invalidToken }), - { mode: 0o600 }, - ) - await assert.rejects( - readPrivateInstanceRecord(recordPath, process.getuid()), - /token/, - ) - }) - }, - ) - } - }, -) - -test('requestDevQuit posts only to loopback and retries 503', async () => { - const requests = [] - const record = { port: 42123, token } - assert.deepEqual( - await requestDevQuit(record, { - requestImpl: requestSequence([503, 202], requests), - retryDelayMs: 1, - timeoutMs: 1_000, - }), - record, - ) - assert.equal(requests.length, 2) - for (const options of requests) { - assert.equal(options.host, '127.0.0.1') - assert.equal(options.port, 42123) - assert.equal(options.method, 'POST') - assert.equal(options.path, '/commands/dev/quit') - assert.equal(options.headers['X-Instance-Token'], token) - } -}) - -test('requestDevQuit retries a missing record source and returns the accepted record', async () => { - let reads = 0 - const record = await requestDevQuit( - async () => { - reads += 1 - if (reads === 1) { - const error = new Error('missing') - error.code = 'ENOENT' - throw error - } - return { port: 42123, token } - }, - { - requestImpl: requestSequence([202], []), - retryDelayMs: 1, - timeoutMs: 1_000, - }, - ) - assert.deepEqual(record, { port: 42123, token }) - assert.equal(reads, 2) -}) - -test('requestDevQuit errors never disclose the instance token', async () => { - await assert.rejects( - requestDevQuit( - { port: 42123, token }, - { requestImpl: requestSequence([500], []) }, - ), - (error) => { - assert.equal(error.message.includes(token), false) - assert.match(error.message, /500/) - return true - }, - ) -}) - -test('non-transient request errors preserve only a token-safe error cause', async () => { - const unsafe = new Error(`permission denied for token ${token}`) - unsafe.code = 'EACCES' - unsafe.options = { headers: { 'X-Instance-Token': token } } - let resultError - - await assert.rejects( - requestDevQuit( - { port: 42123, token }, - { requestImpl: requestSequence([unsafe], []) }, - ), - (error) => { - resultError = error - return true - }, - ) - - const rendered = inspect(resultError, { depth: null }) - assert.equal(rendered.includes(token), false) - assert.match(resultError.cause.message, /EACCES/) - assert.notEqual(resultError.cause, unsafe) -}) - -test('requestDevQuit settles from response headers and consumes shutdown lifecycle events', async () => { - const record = { port: 42123, token } - for (const events of [ - ['aborted', 'close'], - ['error', 'close'], - ]) { - assert.deepEqual( - await requestDevQuit(record, { - requestImpl: responseLifecycleRequest(202, events), - }), - record, - ) - } -}) - -test('requestDevQuit actively releases 503 and 202 responses after headers', async () => { - const releases = [] - assert.deepEqual( - await requestDevQuit( - { port: 42123, token }, - { - requestImpl: releasedResponseSequence([503, 202], releases), - retryDelayMs: 1, - timeoutMs: 100, - }, - ), - { port: 42123, token }, - ) - assert.deepEqual(releases, [503, 202]) -}) - -test('requestDevQuit retries transient connection errors without disclosing the token', async () => { - const transient = new Error(`connect failed with secret ${token}`) - transient.code = 'ECONNREFUSED' - const requests = [] - assert.deepEqual( - await requestDevQuit( - { port: 42123, token }, - { - requestImpl: requestSequence([transient, 202], requests), - retryDelayMs: 1, - timeoutMs: 100, - }, - ), - { port: 42123, token }, - ) - assert.equal(requests.length, 2) -}) - -test('requestDevQuit bounds ENOENT, 503, and transient-error retries', async (t) => { - const cases = [ - { - name: 'ENOENT', - source: async () => { - const error = new Error('missing') - error.code = 'ENOENT' - throw error - }, - requestImpl: requestSequence([], []), - }, - { - name: '503', - source: { port: 42123, token }, - requestImpl: requestSequence(Array(20).fill(503), []), - }, - { - name: 'transient error', - source: { port: 42123, token }, - requestImpl: requestSequence( - Array.from({ length: 20 }, () => { - const error = new Error(`reset ${token}`) - error.code = 'ECONNRESET' - return error - }), - [], - ), - }, - ] - - for (const entry of cases) { - await t.test(entry.name, async () => { - await assert.rejects( - requestDevQuit(entry.source, { - requestImpl: entry.requestImpl, - retryDelayMs: 1, - timeoutMs: 5, - }), - (error) => { - assert.match(error.message, /timed out/) - assert.equal(error.message.includes(token), false) - return true - }, - ) - }) - } -}) - -test('waitForPortClosed resolves only after the loopback connection probe fails', async () => { - let probes = 0 - await waitForPortClosed(42123, { - probePort: async () => { - probes += 1 - return probes < 3 - }, - retryDelayMs: 1, - timeoutMs: 1_000, - }) - assert.equal(probes, 3) -}) - -test('waitForPortClosed gives each probe only the remaining absolute budget', async () => { - let now = 1_000 - const budgets = [] - await assert.rejects( - waitForPortClosed(42123, { - now: () => now, - probePort: async (_port, budget) => { - budgets.push(budget) - return true - }, - retryDelayMs: 3, - sleep: async (milliseconds) => { - now += milliseconds - }, - timeoutMs: 10, - }), - /timed out/, - ) - assert.deepEqual(budgets, [10, 7, 4, 1]) -}) - -test('first SIGINT finalizes the group before exiting 130', async () => { - const calls = [] - const controller = createMacosShutdownController({ - requestQuit: async () => { - calls.push('request') - return { port: 42123 } - }, - waitForPortClosed: async () => { - calls.push('closed') - }, - waitForChildExit: async () => { - calls.push('child') - }, - terminateGroup: () => calls.push('terminate'), - exit: (code) => calls.push(`exit:${code}`), - }) - - await controller.handleSigint() - assert.deepEqual(calls, [ - 'request', - 'closed', - 'child', - 'terminate', - 'exit:130', - ]) -}) - -test('first timeout preserves the group and second SIGINT forces it exactly once', async () => { - const calls = [] - const controller = createMacosShutdownController({ - requestQuit: async () => { - throw new Error('timed out') - }, - waitForPortClosed: async () => {}, - waitForChildExit: async () => {}, - terminateGroup: () => calls.push('terminate'), - exit: (code) => calls.push(`exit:${code}`), - report: (message) => calls.push(message), - }) - - await controller.handleSigint() - assert.equal(calls.includes('terminate'), false) - assert.equal( - calls.some((call) => String(call).includes('timed out')), - true, - ) - await controller.handleSigint() - await controller.handleSigint() - assert.equal(calls.filter((call) => call === 'terminate').length, 1) -}) - -test('second SIGINT terminates the group while the first request is still pending', async () => { - let releaseRequest - const requestPending = new Promise((resolve) => { - releaseRequest = resolve - }) - const calls = [] - const controller = createMacosShutdownController({ - requestQuit: async () => requestPending, - waitForPortClosed: async () => {}, - waitForChildExit: async () => {}, - terminateGroup: () => calls.push('terminate'), - exit: (code) => calls.push(`exit:${code}`), - }) - - const graceful = controller.handleSigint() - await controller.handleSigint() - assert.deepEqual(calls, ['terminate', 'exit:130']) - releaseRequest({ port: 42123 }) - await graceful -}) - -test('third SIGINT shares the in-progress forced termination', async () => { - let finishTermination - const terminating = new Promise((resolve) => { - finishTermination = resolve - }) - const calls = [] - const controller = createMacosShutdownController({ - requestQuit: async () => new Promise(() => {}), - waitForPortClosed: async () => {}, - waitForChildExit: async () => {}, - terminateGroup: async () => { - calls.push('terminate') - await terminating - }, - exit: (code) => calls.push(`exit:${code}`), - }) - - void controller.handleSigint() - const forced = controller.handleSigint() - const repeated = controller.handleSigint() - await flushTasks() - assert.deepEqual(calls, ['terminate']) - finishTermination() - assert.equal(await forced, true) - assert.equal(await repeated, true) - assert.deepEqual(calls, ['terminate', 'exit:130']) -}) - -test('trace invocation preserves the existing Rust flags and Tauri arguments', () => { - const invocation = buildTauriInvocation('trace', { PATH: '/bin' }, 'darwin') - assert.deepEqual(invocation.args, [ - 'exec', - 'tauri', - 'dev', - '-f', - 'verge-dev', - 'tokio-trace', - ]) - assert.equal(invocation.env.RUST_BACKTRACE, 'full') - assert.equal(invocation.env.RUSTFLAGS, '--cfg tokio_unstable') - assert.equal(invocation.detached, true) -}) - -test('normal dev invocation preserves arguments and remains direct off macOS', () => { - const invocation = buildTauriInvocation('dev', { PATH: '/bin' }, 'linux') - assert.equal(invocation.command, 'pnpm') - assert.deepEqual(invocation.args, ['exec', 'tauri', 'dev', '-f', 'verge-dev']) - assert.deepEqual(invocation.env, { PATH: '/bin', RUST_BACKTRACE: 'full' }) - assert.equal(invocation.detached, false) -}) - -test('sidecar invocation explicitly enables only the development sidecar feature', () => { - const invocation = buildTauriInvocation('sidecar', { PATH: '/bin' }, 'win32') - assert.deepEqual(invocation.args, [ - 'exec', - 'tauri', - 'dev', - '-f', - 'verge-dev,dev-sidecar', - ]) - assert.equal(invocation.detached, false) -}) - -test('macOS parent launches a detached Node anchor with inherited stdio and IPC', async () => { - const anchor = createChild(4321) - let spawned - const running = runDevCommand('trace', { - environment: { HOME: '/tmp/home', PATH: '/bin' }, - installSigint: () => () => {}, - platform: 'darwin', - spawnChild: (command, args, options) => { - spawned = { command, args, options } - queueMicrotask(() => { - anchor.emit('spawn') - anchor.emit('message', { type: 'ready' }) - }) - return anchor - }, - }) - await running - - assert.equal(spawned.command, process.execPath) - assert.deepEqual(spawned.args.slice(-2), ['--anchor', 'trace']) - assert.equal(spawned.options.detached, true) - assert.deepEqual(spawned.options.stdio, [ - 'inherit', - 'inherit', - 'inherit', - 'ipc', - ]) -}) - -test('anchor launches the exact inner command non-detached and stays for release', async () => { - const inner = createChild(8765) - const sent = [] - const exits = [] - let handleMessage - let handleSigterm - let spawned - const starting = runDevAnchor('trace', { - environment: { PATH: '/bin' }, - exit: (code) => exits.push(code), - installDisconnect: () => () => {}, - installMessage: (handler) => { - handleMessage = handler - return () => {} - }, - installSigterm: (handler) => { - handleSigterm = handler - return () => {} - }, - send: (message) => sent.push(message), - spawnChild: (command, args, options) => { - spawned = { command, args, options } - queueMicrotask(() => inner.emit('spawn')) - return inner - }, - }) - await starting - - assert.equal(spawned.command, 'pnpm') - assert.deepEqual(spawned.args, [ - 'exec', - 'tauri', - 'dev', - '-f', - 'verge-dev', - 'tokio-trace', - ]) - assert.equal(spawned.options.detached, false) - assert.equal(spawned.options.stdio, 'inherit') - assert.deepEqual(sent, [{ type: 'ready' }]) - - handleSigterm() - assert.deepEqual(exits, []) - inner.emit('exit', 0, null) - await flushTasks() - assert.deepEqual(sent.at(-1), { - type: 'inner-result', - code: 0, - signal: null, - }) - assert.deepEqual(exits, []) - - handleMessage({ type: 'release' }) - assert.deepEqual(exits, [0]) -}) - -test('anchor cleans its own group after parent IPC disconnect', async () => { - const inner = createChild(8765) - const calls = [] - let handleDisconnect - const starting = runDevAnchor('dev', { - environment: { PATH: '/bin' }, - exit: (code) => calls.push(`exit:${code}`), - installDisconnect: (handler) => { - handleDisconnect = handler - return () => {} - }, - installMessage: () => () => {}, - installSigterm: () => () => {}, - ownPid: 4321, - send: () => {}, - spawnChild: () => { - queueMicrotask(() => inner.emit('spawn')) - return inner - }, - terminateGroup: async (pid) => calls.push(`terminate:${pid}`), - }) - await starting - - handleDisconnect() - await flushTasks() - assert.deepEqual(calls, ['terminate:4321', 'exit:1']) -}) - -test('first finalize and second SIGINT share one group finalization', async () => { - let finishFinalize - const finalizing = new Promise((resolve) => { - finishFinalize = resolve - }) - const calls = [] - const controller = createMacosShutdownController({ - exit: (code) => calls.push(`exit:${code}`), - finalizeGroup: async () => { - calls.push('finalize') - await finalizing - }, - requestQuit: async () => ({ port: 42123 }), - terminateGroup: async () => calls.push('force'), - waitForChildExit: async () => {}, - waitForPortClosed: async () => {}, - }) - - const graceful = controller.handleSigint() - await flushTasks() - const forced = controller.handleSigint() - const repeated = controller.handleSigint() - assert.deepEqual(calls, ['finalize']) - finishFinalize() - await Promise.all([graceful, forced, repeated]) - assert.equal(calls.filter((call) => call === 'finalize').length, 1) - assert.equal(calls.includes('force'), false) - assert.deepEqual( - calls.filter((call) => call.startsWith('exit:')), - ['exit:130'], - ) -}) - -test('graceful shutdown shares one absolute ten-second budget', async () => { - let now = 1_000 - const budgets = [] - const controller = createMacosShutdownController({ - exit: () => {}, - finalizeGroup: async (budget) => { - budgets.push(['finalize', budget]) - now += 500 - }, - now: () => now, - requestQuit: async (budget) => { - budgets.push(['request', budget]) - now += 3_000 - return { port: 42123 } - }, - terminateGroup: async () => {}, - timeoutMs: 10_000, - waitForChildExit: async (budget) => { - budgets.push(['inner', budget]) - now += 4_000 - }, - waitForPortClosed: async (_port, budget) => { - budgets.push(['endpoint', budget]) - now += 2_000 - }, - }) - - await controller.handleSigint() - assert.deepEqual(budgets, [ - ['request', 10_000], - ['endpoint', 7_000], - ['inner', 5_000], - ['finalize', 1_000], - ]) -}) - -test('macOS validates HOME before spawning', async () => { - let spawned = false - await assert.rejects( - runDevCommand('dev', { - environment: { PATH: '/bin' }, - platform: 'darwin', - spawnChild: () => { - spawned = true - }, - }), - /HOME/, - ) - assert.equal(spawned, false) -}) - -test('macOS rejects spawn failure and unavailable PID without group signals', async (t) => { - for (const scenario of ['error', 'missing pid']) { - await t.test(scenario, async () => { - const child = createChild(undefined) - const groupSignals = [] - const start = runDevCommand('dev', { - environment: { HOME: '/tmp/home', PATH: '/bin' }, - installSigint: () => () => {}, - platform: 'darwin', - signalGroup: (...args) => groupSignals.push(args), - spawnChild: () => { - queueMicrotask(() => { - if (scenario === 'error') - child.emit('error', new Error('spawn failed')) - else child.emit('spawn') - }) - return child - }, - }) - await assert.rejects(start, scenario === 'error' ? /spawn failed/ : /PID/) - assert.deepEqual(groupSignals, []) - }) - } -}) - -test('SIGINT received during macOS spawn setup waits for a validated child PID', async () => { - const child = createChild(4321) - let handleSigint - const calls = [] - const starting = runDevCommand('dev', { - environment: { HOME: '/tmp/home', PATH: '/bin' }, - exit: (code) => calls.push(`exit:${code}`), - installSigint: (handler) => { - handleSigint = handler - return () => {} - }, - platform: 'darwin', - requestQuit: async () => new Promise(() => {}), - spawnChild: () => child, - terminateGroup: async (pid) => calls.push(`terminate:${pid}`), - }) - await flushTasks() - handleSigint() - handleSigint() - assert.deepEqual(calls, []) - child.emit('spawn') - child.emit('message', { type: 'ready' }) - await starting - await flushTasks() - assert.deepEqual(calls, ['terminate:4321', 'exit:130']) -}) - -test('inner leader exit leaves the anchor available for lingering forced group cleanup', async () => { - const child = createChild(4321) - let handleSigint - let finishTermination - const terminating = new Promise((resolve) => { - finishTermination = resolve - }) - const calls = [] - const started = runDevCommand('dev', { - environment: { HOME: '/tmp/home', PATH: '/bin' }, - exit: (code) => calls.push(`exit:${code}`), - installSigint: (handler) => { - handleSigint = handler - return () => {} - }, - mirrorResult: (result) => - calls.push(`mirror:${result.code}:${result.signal}`), - platform: 'darwin', - report: (message) => calls.push(message), - requestQuit: async () => { - throw new Error('timed out') - }, - spawnChild: () => { - queueMicrotask(() => { - child.emit('spawn') - child.emit('message', { type: 'ready' }) - }) - return child - }, - terminateGroup: async () => { - calls.push('terminate') - await terminating - }, - }) - await started - - handleSigint() - await flushTasks() - child.emit('message', { - type: 'inner-result', - code: 0, - signal: null, - }) - await flushTasks() - assert.equal( - calls.some((call) => call.startsWith('mirror:')), - false, - ) - handleSigint() - handleSigint() - await flushTasks() - assert.equal(calls.filter((call) => call === 'terminate').length, 1) - assert.equal(calls.includes('exit:130'), false) - finishTermination() - await flushTasks() - assert.equal(calls.filter((call) => call === 'exit:130').length, 1) -}) - -test('unexpected anchor exit makes delayed force cleanup fail closed', async () => { - const anchor = createChild(4321) - let handleSigint - const calls = [] - await runDevCommand('dev', { - environment: { HOME: '/tmp/home', PATH: '/bin' }, - exit: (code) => calls.push(`exit:${code}`), - installSigint: (handler) => { - handleSigint = handler - return () => {} - }, - platform: 'darwin', - report: (message) => calls.push(`report:${message}`), - requestQuit: async () => { - throw new Error('timed out') - }, - signalGroup: (pid, signal) => calls.push(`signal:${pid}:${signal}`), - spawnChild: () => { - queueMicrotask(() => { - anchor.emit('spawn') - anchor.emit('message', { type: 'ready' }) - }) - return anchor - }, - }) - - handleSigint() - await flushTasks() - anchor.emit('exit', 1, null) - await flushTasks() - handleSigint() - await flushTasks() - - assert.equal( - calls.some((call) => call.startsWith('signal:')), - false, - ) - assert.equal( - calls.some((call) => call.includes('unsafe')), - true, - ) - assert.deepEqual( - calls.filter((call) => call.startsWith('exit:')), - ['exit:1'], - ) -}) - -test('normal inner completion releases the anchor before mirroring the result', async () => { - const anchor = createChild(4321) - const calls = [] - anchor.send = (message, callback) => { - calls.push(message.type) - queueMicrotask(() => { - callback?.() - anchor.emit('exit', 0, null) - }) - } - await runDevCommand('dev', { - environment: { HOME: '/tmp/home', PATH: '/bin' }, - installSigint: () => () => {}, - mirrorResult: ({ code, signal }) => calls.push(`mirror:${code}:${signal}`), - platform: 'darwin', - signalGroup: (pid, signal) => { - calls.push(`probe:${pid}:${signal}`) - const error = new Error('group absent') - error.code = 'ESRCH' - throw error - }, - spawnChild: () => { - queueMicrotask(() => { - anchor.emit('spawn') - anchor.emit('message', { type: 'ready' }) - }) - return anchor - }, - }) - - anchor.emit('message', { - type: 'inner-result', - code: 0, - signal: null, - }) - await flushTasks() - await flushTasks() - assert.deepEqual(calls, ['release', 'probe:-4321:0', 'mirror:0:null']) -}) - -test('normal release fails closed while the original group still exists', async () => { - const anchor = createChild(4321) - const calls = [] - anchor.send = (message, callback) => { - calls.push(message.type) - queueMicrotask(() => { - callback?.() - anchor.emit('exit', 0, null) - }) - } - await runDevCommand('dev', { - environment: { HOME: '/tmp/home', PATH: '/bin' }, - exit: (code) => calls.push(`exit:${code}`), - installSigint: () => () => {}, - mirrorResult: () => calls.push('mirror'), - platform: 'darwin', - report: (message) => calls.push(`report:${message}`), - signalGroup: (pid, signal) => calls.push(`probe:${pid}:${signal}`), - spawnChild: () => { - queueMicrotask(() => { - anchor.emit('spawn') - anchor.emit('message', { type: 'ready' }) - }) - return anchor - }, - }) - - anchor.emit('message', { - type: 'inner-result', - code: 0, - signal: null, - }) - await flushTasks() - await flushTasks() - - assert.deepEqual(calls, [ - 'release', - 'probe:-4321:0', - 'report:Dev command failed: dev process group still exists after anchor exit', - 'exit:1', - ]) -}) - -test('failed forced cleanup is shared, reported once, and never mirrors child exit', async () => { - const child = createChild(4321) - let handleSigint - const calls = [] - await runDevCommand('dev', { - environment: { HOME: '/tmp/home', PATH: '/bin' }, - exit: (code) => calls.push(`exit:${code}`), - installSigint: (handler) => { - handleSigint = handler - return () => {} - }, - mirrorResult: () => calls.push('mirror'), - platform: 'darwin', - report: (message) => calls.push(`report:${message}`), - requestQuit: async () => new Promise(() => {}), - spawnChild: () => { - queueMicrotask(() => { - child.emit('spawn') - child.emit('message', { type: 'ready' }) - }) - return child - }, - terminateGroup: async () => { - calls.push('terminate') - throw new Error('forced cleanup failed') - }, - }) - - handleSigint() - handleSigint() - handleSigint() - child.emit('exit', 0, null) - await flushTasks() - await flushTasks() - - assert.equal(calls.filter((call) => call === 'terminate').length, 1) - assert.equal(calls.filter((call) => call.startsWith('report:')).length, 1) - assert.deepEqual( - calls.filter((call) => call.startsWith('exit:')), - ['exit:1'], - ) - assert.equal(calls.includes('mirror'), false) -}) - -test('detached group termination waits after TERM, then treats KILL ESRCH as success', async () => { - let now = 0 - const calls = [] - await terminateDetachedGroup(4321, { - graceMs: 100, - now: () => now, - signalGroup: (pid, signal) => { - calls.push(`${pid}:${signal}`) - if (signal === 'SIGKILL') { - const error = new Error('gone') - error.code = 'ESRCH' - throw error - } - }, - sleep: async (milliseconds) => { - calls.push(`sleep:${milliseconds}`) - now += milliseconds - }, - }) - assert.deepEqual(calls, [ - '-4321:SIGTERM', - 'sleep:50', - '-4321:0', - 'sleep:50', - '-4321:SIGKILL', - ]) -}) - -test('non-macOS wiring mirrors child exit codes and signals without installing SIGINT', async (t) => { - for (const result of [ - { code: 7, signal: null }, - { code: null, signal: 'SIGTERM' }, - ]) { - await t.test( - result.code === null ? result.signal : String(result.code), - async () => { - const child = createChild(4321) - const calls = [] - const running = runDevCommand('dev', { - environment: { PATH: '/bin' }, - installSigint: () => { - throw new Error('must not install SIGINT') - }, - mirrorResult: (childResult) => - mirrorChildExit(childResult, { - exit: (code) => calls.push(`exit:${code}`), - signalSelf: (signal) => calls.push(`signal:${signal}`), - }), - platform: 'linux', - spawnChild: () => { - queueMicrotask(() => child.emit('exit', result.code, result.signal)) - return child - }, - }) - await running - assert.deepEqual(calls, [ - result.code === null ? 'signal:SIGTERM' : 'exit:7', - ]) - }, - ) - } -}) - -test('non-macOS spawn errors consume every lifecycle rejection', async () => { - const child = createChild(undefined) - const unhandled = [] - const captureUnhandled = (error) => unhandled.push(error) - process.prependListener('unhandledRejection', captureUnhandled) - try { - const running = runDevCommand('dev', { - environment: { PATH: '/bin' }, - platform: 'linux', - spawnChild: () => { - queueMicrotask(() => child.emit('error', new Error('spawn failed'))) - return child - }, - }) - await assert.rejects(running, /spawn failed/) - await flushTasks() - assert.deepEqual(unhandled, []) - } finally { - process.removeListener('unhandledRejection', captureUnhandled) - } -}) diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index 2497e54fe..a633db8d4 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -166,24 +166,26 @@ impl WithErrorCode for Result { #[cfg(test)] mod tests { - use super::{CommandFailure, WithErrorCode as _, coded_error, proxy_aware_coded_error, proxy_aware_error}; - use crate::core::proxy_control::SysproxyFailure; use anyhow::Context as _; + use super::{CommandFailure, WithErrorCode as _, proxy_aware_coded_error}; + use crate::core::proxy_control::SysproxyFailure; + #[test] - fn coded_error_preserves_stable_code_and_diagnostic_detail() { + fn command_failure_matches_the_frontend_wire_contract() { assert_eq!( - coded_error("CORE_RESTART_FAILED", "connection refused"), - CommandFailure { - code: Some("CORE_RESTART_FAILED".to_owned()), - detail: "connection refused".to_owned(), - operation: None, - } + serde_json::to_value(CommandFailure::coded("CORE_START_FAILED", "connection refused")) + .unwrap_or(serde_json::Value::Null), + serde_json::json!({ "code": "CORE_START_FAILED", "detail": "connection refused" }) + ); + assert_eq!( + serde_json::to_value(CommandFailure::plain("disk full")).unwrap_or(serde_json::Value::Null), + serde_json::json!({ "detail": "disk full" }) ); } #[test] - fn with_error_code_preserves_whole_anyhow_context_chain() { + fn coded_errors_preserve_the_anyhow_context_chain() { let source: anyhow::Result<()> = Err(anyhow::anyhow!("connection refused")) .context("failed to reach the mihomo core") .context("failed to restart the core"); @@ -193,37 +195,15 @@ mod tests { .err() .unwrap_or_else(|| CommandFailure::plain("the call should have failed")); + assert_eq!(failure.code.as_deref(), Some("CORE_RESTART_FAILED")); assert_eq!( - failure, - CommandFailure { - code: Some("CORE_RESTART_FAILED".to_owned()), - detail: "failed to restart the core: failed to reach the mihomo core: connection refused".to_owned(), - operation: None, - } + failure.detail, + "failed to restart the core: failed to reach the mihomo core: connection refused" ); } #[test] - fn with_error_code_leaves_plain_display_errors_untouched() { - let source: Result<(), &str> = Err("plain failure"); - - let failure = source - .with_error_code("PROFILE_READ_FAILED") - .err() - .unwrap_or_else(|| CommandFailure::plain("the call should have failed")); - - assert_eq!( - failure, - CommandFailure { - code: Some("PROFILE_READ_FAILED".to_owned()), - detail: "plain failure".to_owned(), - operation: None, - } - ); - } - - #[test] - fn a_classification_outranks_the_code_naming_the_operation() { + fn system_proxy_classification_outranks_the_operation_code() { let failure = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges) .context(SysproxyFailure::PrivilegeRequired) .context("failed to restart the core"); @@ -231,94 +211,6 @@ mod tests { let reported = proxy_aware_coded_error(&failure, "CORE_RESTART_FAILED"); assert_eq!(reported.code.as_deref(), Some("SYSPROXY_PRIVILEGE_REQUIRED")); - assert!(reported.detail.contains("failed to restart the core"), "{reported:?}"); - assert!(reported.detail.contains("admin privileges"), "{reported:?}"); - } - - #[test] - fn without_a_classification_the_callers_code_is_used() { - let failure = anyhow::anyhow!("connection refused").context("failed to start the core"); - - let reported = proxy_aware_coded_error(&failure, "CORE_START_FAILED"); - - assert_eq!( - reported, - CommandFailure { - code: Some("CORE_START_FAILED".to_owned()), - detail: "failed to start the core: connection refused".to_owned(), - operation: None, - } - ); - } - - #[test] - fn commands_that_report_plain_text_stay_uncoded_without_a_classification() { - let failure = anyhow::anyhow!("disk full").context("failed to save the configuration"); - - let reported = proxy_aware_error(&failure); - - assert_eq!( - reported, - CommandFailure { - code: None, - detail: "failed to save the configuration: disk full".to_owned(), - operation: None, - } - ); - } - - #[test] - fn commands_that_report_plain_text_still_carry_a_classification() { - let failure = anyhow::Error::new(SysproxyFailure::DirectFallback { - detail: "service could not set the proxy".to_owned(), - }) - .context("failed to apply the verge patch"); - - let reported = proxy_aware_error(&failure); - - assert_eq!(reported.code.as_deref(), Some("SYSPROXY_DIRECT_FALLBACK")); - assert!( - reported.detail.contains("failed to apply the verge patch"), - "{reported:?}" - ); - assert!( - reported.detail.contains("service could not set the proxy"), - "{reported:?}" - ); - } - - #[test] - fn a_command_that_knows_the_request_can_say_which_way_it_went() { - let asking_on = CommandFailure::coded("SYSPROXY_PRIVILEGE_REQUIRED", "refused") - .asking_for(Some(crate::core::notification::FailedOperation::SystemProxyEnable)); - - assert_eq!( - serde_json::to_value(asking_on).unwrap_or(serde_json::Value::Null), - serde_json::json!({ - "code": "SYSPROXY_PRIVILEGE_REQUIRED", - "detail": "refused", - "operation": "systemProxyEnable", - }) - ); - - assert_eq!( - serde_json::to_value(CommandFailure::coded("CORE_START_FAILED", "no").asking_for(None)) - .unwrap_or(serde_json::Value::Null), - serde_json::json!({ "code": "CORE_START_FAILED", "detail": "no" }) - ); - } - - #[test] - fn the_serialised_shape_is_what_the_frontend_reads() { - assert_eq!( - serde_json::to_value(CommandFailure::coded("CORE_START_FAILED", "connection refused")) - .unwrap_or(serde_json::Value::Null), - serde_json::json!({ "code": "CORE_START_FAILED", "detail": "connection refused" }) - ); - - assert_eq!( - serde_json::to_value(CommandFailure::plain("disk full")).unwrap_or(serde_json::Value::Null), - serde_json::json!({ "detail": "disk full" }) - ); + assert!(reported.detail.contains("failed to restart the core")); } } diff --git a/src-tauri/src/cmd/verge.rs b/src-tauri/src/cmd/verge.rs index b71c45ef9..2ab25fd3e 100644 --- a/src-tauri/src/cmd/verge.rs +++ b/src-tauri/src/cmd/verge.rs @@ -32,31 +32,3 @@ const fn system_proxy_operation(payload: &IVerge) -> Option { None => None, } } - -#[cfg(test)] -mod tests { - use super::system_proxy_operation; - use crate::{config::IVerge, core::notification::FailedOperation}; - - #[test] - fn only_a_system_proxy_toggle_says_which_way_it_was_going() { - let asking_on = IVerge { - enable_system_proxy: Some(true), - ..IVerge::default() - }; - let asking_off = IVerge { - enable_system_proxy: Some(false), - ..IVerge::default() - }; - - assert_eq!( - system_proxy_operation(&asking_on), - Some(FailedOperation::SystemProxyEnable) - ); - assert_eq!( - system_proxy_operation(&asking_off), - Some(FailedOperation::SystemProxyDisable) - ); - assert_eq!(system_proxy_operation(&IVerge::default()), None); - } -} diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index c07ed48c3..c03ede8e0 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -385,37 +385,10 @@ pub enum ConfigType { Run, Check, } + #[cfg(test)] mod tests { use super::*; - use std::mem; - - #[test] - #[allow(unused_variables)] - #[allow(clippy::expect_used)] - fn test_prfitem_from_merge_size() { - let merge_item = PrfItem::from_merge(Some("Merge".into())).expect("Failed to create merge item in test"); - let prfitem_size = mem::size_of_val(&merge_item); - let boxed_merge_item = Box::new(merge_item); - let box_prfitem_size = mem::size_of_val(&boxed_merge_item); - assert!(box_prfitem_size < prfitem_size); - } - - #[test] - #[allow(unused_variables)] - fn test_draft_size_non_boxed() { - let draft = Draft::new(IRuntime::new()); - let iruntime_size = std::mem::size_of_val(&draft); - assert_eq!(iruntime_size, std::mem::size_of::>()); - } - - #[test] - #[allow(unused_variables)] - fn test_draft_size_boxed() { - let draft = Draft::new(Box::new(IRuntime::new())); - let box_iruntime_size = std::mem::size_of_val(&draft); - assert_eq!(box_iruntime_size, std::mem::size_of::>>()); - } #[tokio::test] async fn failed_profile_index_survives_startup_without_cleanup() -> Result<()> { @@ -431,14 +404,8 @@ mod tests { let profile_was_preserved = tokio::fs::try_exists(&active_profile).await?; tokio::fs::remove_dir_all(&profiles_dir).await?; - assert!( - profile_was_preserved, - "startup must not delete profiles when profiles.yaml could not be loaded" - ); - assert!( - profiles.data_arc().get_items().is_none(), - "startup must not replace an unreadable profile index with defaults" - ); + assert!(profile_was_preserved); + assert!(profiles.data_arc().get_items().is_none()); Ok(()) } } diff --git a/src-tauri/src/config/mixed_port.rs b/src-tauri/src/config/mixed_port.rs index b172fe7c6..85ebbe4e2 100644 --- a/src-tauri/src/config/mixed_port.rs +++ b/src-tauri/src/config/mixed_port.rs @@ -73,40 +73,23 @@ where } #[cfg(test)] -#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] mod tests { use super::*; #[test] - fn a_selected_port_wins_over_the_merge_config() { - assert_eq!(resolve_desired(None, Some(9000), 7897), 9000); - } - - #[test] - fn the_merge_config_answers_when_nothing_is_selected() { + fn desired_port_uses_session_then_selection_then_merge_priority() { + assert_eq!(resolve_desired(Some(7900), Some(9000), 8080), 7900); + assert_eq!(resolve_desired(None, Some(9000), 8080), 9000); assert_eq!(resolve_desired(None, None, 8080), 8080); } - #[test] - fn a_session_fallback_outranks_the_port_still_written_down() { - assert_eq!(resolve_desired(Some(7900), Some(7897), 7897), 7900); - } - #[tokio::test] - async fn a_reporting_core_overrides_what_we_configured() { + async fn effective_port_prefers_a_bound_core_port() { assert_eq!(resolve_effective(|| async { Ok(7898) }, 7897).await, 7898); - } - - #[tokio::test] - async fn an_unreachable_core_leaves_the_configured_port_standing() { + assert_eq!(resolve_effective(|| async { Ok(0) }, 7897).await, 7897); assert_eq!( resolve_effective(|| async { anyhow::bail!("core is not running") }, 7897).await, 7897 ); } - - #[tokio::test] - async fn a_core_that_has_not_bound_yet_is_not_believed() { - assert_eq!(resolve_effective(|| async { Ok(0) }, 7897).await, 7897); - } } diff --git a/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index a7257450d..96b0a3215 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -565,19 +565,3 @@ fn fix_dirty_url(input: &str) -> Result { Ok(url) } - -#[cfg(test)] -mod tests { - use super::{PrfOption, allow_auto_update_enabled}; - - #[test] - fn auto_update_defaults_to_enabled_and_preserves_explicit_false() { - assert!(allow_auto_update_enabled(None)); - - let disabled = PrfOption { - allow_auto_update: Some(false), - ..PrfOption::default() - }; - assert!(!allow_auto_update_enabled(Some(&disabled))); - } -} diff --git a/src-tauri/src/core/logger.rs b/src-tauri/src/core/logger.rs index 5bee2c193..6d4529c55 100644 --- a/src-tauri/src/core/logger.rs +++ b/src-tauri/src/core/logger.rs @@ -234,16 +234,3 @@ impl Logger { } } } - -#[cfg(test)] -mod tests { - use super::should_sync_service_writer; - use crate::core::manager::RunningMode; - - #[test] - fn service_writer_sync_requires_service_running_mode() { - assert!(should_sync_service_writer(RunningMode::Service)); - assert!(!should_sync_service_writer(RunningMode::Sidecar)); - assert!(!should_sync_service_writer(RunningMode::NotRunning)); - } -} diff --git a/src-tauri/src/core/notification.rs b/src-tauri/src/core/notification.rs index dcae04646..e1745db30 100644 --- a/src-tauri/src/core/notification.rs +++ b/src-tauri/src/core/notification.rs @@ -298,151 +298,3 @@ impl NotificationSystem { } } } - -#[cfg(test)] -mod tests { - use super::{FailedOperation, FailureTable}; - - fn table() -> FailureTable { - FailureTable::default() - } - - #[test] - fn reading_does_not_clear() { - let table = table(); - table.record( - FailedOperation::SystemProxyRestore, - "SYSPROXY_PRIVILEGE_REQUIRED", - "refused".into(), - ); - - assert_eq!(table.snapshot(), table.snapshot()); - assert_eq!(table.snapshot().len(), 1); - } - - #[test] - fn a_code_keeps_only_its_most_recent_failure() { - let table = table(); - table.record( - FailedOperation::SystemProxyRestore, - "SYSPROXY_PRIVILEGE_REQUIRED", - "first".into(), - ); - table.record( - FailedOperation::SystemProxyRestore, - "SYSPROXY_PRIVILEGE_REQUIRED", - "second".into(), - ); - - let failures = table.snapshot(); - assert_eq!(failures.len(), 1); - assert_eq!(failures[0].detail, "second"); - } - - #[test] - fn a_new_failure_under_a_known_code_gets_a_new_sequence() { - let table = table(); - table.record( - FailedOperation::SystemProxyRestore, - "SYSPROXY_DIRECT_FALLBACK", - "a".into(), - ); - let first = table.snapshot()[0].sequence; - table.record( - FailedOperation::SystemProxyRestore, - "SYSPROXY_DIRECT_FALLBACK", - "b".into(), - ); - - assert!(table.snapshot()[0].sequence > first); - } - - #[test] - fn failures_are_reported_oldest_first() { - let table = table(); - table.record(FailedOperation::SystemProxyRestore, "FIRST", "a".into()); - table.record(FailedOperation::SystemProxyRestore, "SECOND", "b".into()); - - let failures = table.snapshot(); - let codes: Vec<&str> = failures.iter().map(|failure| failure.code.as_str()).collect(); - assert_eq!(codes, ["FIRST", "SECOND"]); - } - - #[test] - fn a_working_proxy_retires_what_it_disproves() { - let table = table(); - table.record( - FailedOperation::SystemProxyRestore, - "SYSPROXY_PRIVILEGE_REQUIRED", - "refused".into(), - ); - - assert!(table.retire_system_proxy(false)); - assert!(table.snapshot().is_empty()); - } - - #[test] - fn a_users_request_is_retired_only_by_reaching_the_state_they_asked_for() { - let table = table(); - table.record( - FailedOperation::SystemProxyDisable, - "SYSPROXY_PRIVILEGE_REQUIRED", - "refused".into(), - ); - - assert!(!table.retire_system_proxy(true)); - assert_eq!(table.snapshot().len(), 1); - - assert!(table.retire_system_proxy(false)); - assert!(table.snapshot().is_empty()); - } - - #[test] - fn a_failed_enable_is_retired_by_the_proxy_coming_on() { - let table = table(); - table.record( - FailedOperation::SystemProxyEnable, - "SYSPROXY_DIRECT_FALLBACK", - "fell back".into(), - ); - - assert!(!table.retire_system_proxy(false)); - assert!(table.retire_system_proxy(true)); - assert!(table.snapshot().is_empty()); - } - - #[test] - fn a_stopped_guard_is_retired_by_a_guard_starting_again_and_not_by_an_apply() { - let table = table(); - table.record( - FailedOperation::SystemProxyGuard, - "SYSPROXY_GUARD_STOPPED", - "gave up".into(), - ); - - assert!(!table.retire_system_proxy(true)); - assert!(!table.retire_system_proxy(false)); - assert_eq!(table.snapshot().len(), 1); - - assert!(table.retire_guard()); - assert!(table.snapshot().is_empty()); - } - - #[test] - fn retiring_a_guard_leaves_everything_else_alone() { - let table = table(); - table.record( - FailedOperation::SystemProxyEnable, - "SYSPROXY_PRIVILEGE_REQUIRED", - "refused".into(), - ); - - assert!(!table.retire_guard()); - assert_eq!(table.snapshot().len(), 1); - } - - #[test] - fn retiring_nothing_reports_nothing() { - assert!(!table().retire_system_proxy(true)); - } -} diff --git a/src-tauri/src/core/updater.rs b/src-tauri/src/core/updater.rs index d64ad77bc..b33a8438f 100644 --- a/src-tauri/src/core/updater.rs +++ b/src-tauri/src/core/updater.rs @@ -492,73 +492,3 @@ impl SilentUpdater { } } } - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - #[test] - fn test_version_equal() { - assert!(version_lte("2.4.7", "2.4.7")); - } - - #[test] - fn test_version_less() { - assert!(version_lte("2.4.7", "2.4.8")); - assert!(version_lte("2.4.7", "2.5.0")); - assert!(version_lte("2.4.7", "3.0.0")); - } - - #[test] - fn test_version_greater() { - assert!(!version_lte("2.4.8", "2.4.7")); - assert!(!version_lte("2.5.0", "2.4.7")); - assert!(!version_lte("3.0.0", "2.4.7")); - } - - #[test] - fn test_version_with_v_prefix() { - assert!(version_lte("v2.4.7", "2.4.8")); - assert!(version_lte("2.4.7", "v2.4.8")); - assert!(version_lte("v2.4.7", "v2.4.8")); - } - - #[test] - fn test_version_with_prerelease() { - assert!(version_lte("2.4.7", "2.4.8-alpha")); - assert!(version_lte("2.4.8-alpha", "2.4.8")); - assert!(version_lte("2.4.8-alpha", "2.4.8-beta")); - } - - #[test] - fn test_version_different_lengths() { - assert!(version_lte("2.4", "2.4.1")); - assert!(!version_lte("2.4.1", "2.4")); - assert!(version_lte("2.4.0", "2.4")); - } - - #[test] - fn test_cache_meta_serialize_roundtrip() { - let meta = UpdateCacheMeta { - version: "2.5.0".to_string(), - downloaded_at: "2026-03-31T00:00:00Z".to_string(), - }; - let json = serde_json::to_string(&meta).unwrap(); - let parsed: UpdateCacheMeta = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.version, "2.5.0"); - assert_eq!(parsed.downloaded_at, "2026-03-31T00:00:00Z"); - } - - #[test] - fn test_cache_meta_invalid_json() { - let result = serde_json::from_str::("not valid json"); - assert!(result.is_err()); - } - - #[test] - fn test_cache_meta_missing_required_field() { - let result = serde_json::from_str::(r#"{"version":"2.5.0"}"#); - assert!(result.is_err()); // missing downloaded_at - } -} diff --git a/src-tauri/src/feat/icon.rs b/src-tauri/src/feat/icon.rs index e1423c40f..e78082629 100644 --- a/src-tauri/src/feat/icon.rs +++ b/src-tauri/src/feat/icon.rs @@ -173,103 +173,3 @@ pub async fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult"#)); - } - - #[test] - fn looks_like_svg_accepts_xml_prefixed_svg() { - assert!(looks_like_svg( - br#""# - )); - } - - #[test] - fn looks_like_svg_accepts_doctype_svg() { - assert!(looks_like_svg( - br#""# - )); - } - - #[test] - fn looks_like_svg_accepts_bom_and_leading_whitespace() { - assert!(looks_like_svg( - b"\xEF\xBB\xBF \n\t" - )); - } - - #[test] - fn looks_like_svg_rejects_non_svg_payloads() { - assert!(!looks_like_svg(br#"{"status":"ok"}"#)); - assert!(!looks_like_svg(br"text/plain")); - } - - #[test] - fn looks_like_html_detects_common_html_prefixes() { - assert!(looks_like_html(br"")); - assert!(looks_like_html(br"oops")); - assert!(looks_like_html(br"oops")); - assert!(looks_like_html( - b"\xEF\xBB\xBF \n\toops" - )); - } - - #[test] - fn is_supported_icon_content_rejects_html_and_accepts_svg() { - assert!(!is_supported_icon_content(br"")); - assert!(is_supported_icon_content( - br#""# - )); - } -} diff --git a/src-tauri/src/feat/proxy.rs b/src-tauri/src/feat/proxy.rs index e1d00172e..d5226dfeb 100644 --- a/src-tauri/src/feat/proxy.rs +++ b/src-tauri/src/feat/proxy.rs @@ -137,15 +137,3 @@ pub async fn copy_clash_env() { logging!(error, Type::ProxyMode, "Failed to write to clipboard"); } } - -#[cfg(test)] -mod tests { - use super::toggle_operation; - use crate::core::notification::FailedOperation; - - #[test] - fn a_failed_toggle_records_which_way_the_user_was_asking() { - assert_eq!(toggle_operation(true), FailedOperation::SystemProxyEnable); - assert_eq!(toggle_operation(false), FailedOperation::SystemProxyDisable); - } -} diff --git a/src-tauri/src/utils/notification.rs b/src-tauri/src/utils/notification.rs index 35f5b50f0..0dc3ea213 100644 --- a/src-tauri/src/utils/notification.rs +++ b/src-tauri/src/utils/notification.rs @@ -103,19 +103,3 @@ pub async fn notify_event<'a>(event: NotificationEvent<'a>) { } } } - -#[cfg(test)] -mod tests { - use super::needs_system_notification; - use crate::utils::window_manager::WindowState; - - #[test] - fn a_system_notification_is_sent_exactly_where_the_window_cannot_speak() { - assert!(needs_system_notification(WindowState::Hidden)); - assert!(needs_system_notification(WindowState::Minimized)); - assert!(needs_system_notification(WindowState::NotExist)); - - assert!(!needs_system_notification(WindowState::VisibleFocused)); - assert!(!needs_system_notification(WindowState::VisibleUnfocused)); - } -} diff --git a/src-tauri/src/utils/resolve/window.rs b/src-tauri/src/utils/resolve/window.rs index ed4500557..d4644cba4 100644 --- a/src-tauri/src/utils/resolve/window.rs +++ b/src-tauri/src/utils/resolve/window.rs @@ -184,26 +184,3 @@ pub fn reload_main_window_if_needed() { logging!(warn, Type::Window, "重载页面失败: {e}"); } } - -#[cfg(test)] -mod tests { - use super::restored_window_size_is_too_small; - - #[test] - fn restored_window_size_rejects_zero_dimensions() { - assert!(restored_window_size_is_too_small(0, 700)); - assert!(restored_window_size_is_too_small(940, 0)); - } - - #[test] - fn restored_window_size_rejects_dimensions_below_minimum() { - assert!(restored_window_size_is_too_small(519, 700)); - assert!(restored_window_size_is_too_small(940, 519)); - } - - #[test] - fn restored_window_size_accepts_minimum_or_larger_dimensions() { - assert!(!restored_window_size_is_too_small(520, 520)); - assert!(!restored_window_size_is_too_small(940, 700)); - } -} diff --git a/src-tauri/src/utils/singleton.rs b/src-tauri/src/utils/singleton.rs index a39d8fcc7..4dc94e478 100644 --- a/src-tauri/src/utils/singleton.rs +++ b/src-tauri/src/utils/singleton.rs @@ -21,28 +21,3 @@ macro_rules! singleton { } }; } - -#[cfg(test)] -mod tests { - struct TestStruct { - value: i32, - } - - impl TestStruct { - fn new() -> Self { - Self { value: 42 } - } - } - - singleton!(TestStruct, TEST_INSTANCE); - - #[test] - fn test_singleton_macro() { - let instance1 = TestStruct::global(); - let instance2 = TestStruct::global(); - - assert_eq!(instance1.value, 42); - assert_eq!(instance2.value, 42); - assert!(std::ptr::eq(instance1, instance2)); - } -} diff --git a/src-tauri/src/utils/speed.rs b/src-tauri/src/utils/speed.rs index c8f529019..296126b72 100644 --- a/src-tauri/src/utils/speed.rs +++ b/src-tauri/src/utils/speed.rs @@ -30,31 +30,3 @@ pub fn format_bytes_per_second(bytes_per_sec: u64) -> String { format!("{:.0}{}", value.round(), SPEED_UNITS[unit_index]) } } - -#[cfg(test)] -mod tests { - use super::format_bytes_per_second; - - #[test] - fn format_handles_byte_boundaries() { - assert_eq!(format_bytes_per_second(0), "0B/s"); - assert_eq!(format_bytes_per_second(999), "999B/s"); - assert_eq!(format_bytes_per_second(1000), "1.0K/s"); - assert_eq!(format_bytes_per_second(1024), "1.0K/s"); - } - - #[test] - fn format_handles_decimal_and_integer_rules() { - assert_eq!(format_bytes_per_second(9 * 1024), "9.0K/s"); - assert_eq!(format_bytes_per_second(10 * 1024 - 1), "10K/s"); - assert_eq!(format_bytes_per_second(10 * 1024), "10K/s"); - assert_eq!(format_bytes_per_second(123 * 1024), "123K/s"); - } - - #[test] - fn format_handles_unit_promotion_after_rounding() { - assert_eq!(format_bytes_per_second(999 * 1024 + 512), "1.0M/s"); - assert_eq!(format_bytes_per_second(1024 * 1024), "1.0M/s"); - assert_eq!(format_bytes_per_second(1536 * 1024), "1.5M/s"); - } -} diff --git a/src-tauri/src/utils/startup.rs b/src-tauri/src/utils/startup.rs index 507943c4a..31d8348f2 100644 --- a/src-tauri/src/utils/startup.rs +++ b/src-tauri/src/utils/startup.rs @@ -63,23 +63,3 @@ fn append_error(path: &Path, detail: &str) -> Result<()> { file.flush().context("failed to flush startup log")?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::append_error; - - #[test] - fn startup_log_is_created_and_appended() -> anyhow::Result<()> { - let root = std::env::temp_dir().join(format!("startup-error-log-{}", std::process::id())); - let path = root.join("nested/startup.log"); - - append_error(&path, "first failure")?; - append_error(&path, "second failure")?; - - let content = std::fs::read_to_string(&path)?; - assert!(content.contains("ERROR [Startup] first failure")); - assert!(content.contains("ERROR [Startup] second failure")); - std::fs::remove_dir_all(root)?; - Ok(()) - } -} diff --git a/src/components/layout/notice-manager.test.tsx b/src/components/layout/notice-manager.test.tsx deleted file mode 100644 index 9efa189a7..000000000 --- a/src/components/layout/notice-manager.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -// @vitest-environment jsdom -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' -import i18next from 'i18next' -import { initReactI18next } from 'react-i18next' -import { afterEach, beforeAll, expect, it, vi } from 'vitest' - -import { - getSnapshotNotices, - hideNotice, - showNotice, -} from '@/services/notice-service' - -import { NoticeManager } from './notice-manager' - -const writeText = vi.fn((_text: string) => Promise.resolve()) -const restartCore = vi.hoisted(() => vi.fn(() => Promise.resolve())) -const installService = vi.hoisted(() => vi.fn(() => Promise.resolve())) -const getRuntimeState = vi.hoisted(() => vi.fn()) - -vi.mock('@/services/cmds', () => ({ - restartCore, - installService, - getRuntimeState, -})) -vi.mock('@/utils/get-system', () => ({ default: () => 'macos' })) - -beforeAll(async () => { - await i18next.use(initReactI18next).init({ - lng: 'en', - resources: { - en: { - translation: { - settings: { - modals: { - backup: { - messages: { backupFailed: 'Backup failed: {{error}}' }, - }, - }, - }, - }, - }, - }, - interpolation: { escapeValue: false }, - }) - Object.defineProperty(navigator, 'clipboard', { - value: { writeText }, - configurable: true, - }) -}) - -afterEach(() => { - restartCore.mockClear() - restartCore.mockImplementation(() => Promise.resolve()) - installService.mockClear() - installService.mockImplementation(() => Promise.resolve()) - getRuntimeState.mockReset() - getRuntimeState.mockResolvedValue({ mode: 'Service', serviceUsable: true }) - cleanup() - getSnapshotNotices().forEach((notice) => hideNotice(notice.id)) - writeText.mockClear() -}) - -const noticeText = () => screen.getByRole('alert').textContent ?? '' - -it('renders a failure detail that fits, in full', () => { - showNotice.error({ code: 'CORE_START_FAILED', detail: 'connection refused' }) - render() - - expect(noticeText()).toContain('connection refused') -}) - -it('shortens an unbounded detail', () => { - showNotice.error({ - code: 'SERVICE_INSTALL_FAILED', - detail: `${'x'.repeat(4000)} tail`, - }) - render() - - expect(noticeText().length).toBeLessThanOrEqual(501) - expect(noticeText()).not.toContain('tail') - expect(noticeText()).toContain('…') -}) - -it('shortens a detail a caller interpolated into its own message', () => { - showNotice.error('profiles.page.feedback.notices.emergencyRefreshFailed', { - message: 'y'.repeat(4000), - }) - render() - - expect(noticeText().length).toBeLessThanOrEqual(501) - expect(noticeText()).toContain('…') -}) - -it('renders a failure a caller interpolated, not [object Object]', () => { - showNotice.error('settings.modals.backup.messages.backupFailed', { - error: { code: 'BACKUP_FAILED', detail: 'webdav returned 401' }, - }) - render() - - expect(noticeText()).toBe('Backup failed: webdav returned 401') -}) - -it('renders a failure inside a descriptor, not [object Object]', () => { - showNotice.error({ - key: 'settings.modals.backup.messages.backupFailed', - params: { error: { code: 'BACKUP_FAILED', detail: 'webdav returned 401' } }, - }) - render() - - expect(noticeText()).toBe('Backup failed: webdav returned 401') -}) - -it('leaves the ordinary lifetime alone where there is nothing to offer', () => { - showNotice.error({ code: 'CORE_START_FAILED', detail: 'connection refused' }) - - expect(getSnapshotNotices().at(-1)?.duration).toBeGreaterThan(0) -}) - -it('finds the code a caller interpolated into its own message', () => { - showNotice.error('settings.modals.backup.messages.backupFailed', { - error: { code: 'SYSPROXY_GUARD_STOPPED', detail: 'the guard gave up' }, - }) - - expect(getSnapshotNotices().at(-1)?.code).toBe('SYSPROXY_GUARD_STOPPED') -}) -it('shows one notice per code, replacing rather than stacking', async () => { - showNotice.error({ code: 'SYSPROXY_GUARD_STOPPED', detail: 'first' }) - showNotice.error({ code: 'SYSPROXY_GUARD_STOPPED', detail: 'second' }) - render() - - expect(screen.getAllByRole('alert')).toHaveLength(1) - expect(screen.getByRole('alert').textContent).toContain('second') -}) - -it('remembers which way the user was asking, for whoever acts on it next', () => { - showNotice.error({ - code: 'SYSPROXY_GUARD_STOPPED', - detail: 'admin privileges required to modify system proxy', - operation: 'systemProxyEnable', - }) - - expect(getSnapshotNotices().at(-1)?.operation).toBe('systemProxyEnable') -}) - -it('copies the whole detail, not the shortened one', async () => { - const detail = `${'x'.repeat(4000)} tail` - showNotice.error({ code: 'SERVICE_INSTALL_FAILED', detail }) - render() - - fireEvent.contextMenu(screen.getByRole('alert')) - - await waitFor(() => expect(writeText).toHaveBeenCalledOnce()) - expect(writeText).toHaveBeenCalledWith(detail) -}) - -it('copies the whole message when the caller interpolated the detail', async () => { - const detail = 'y'.repeat(4000) - showNotice.error('profiles.page.feedback.notices.emergencyRefreshFailed', { - message: detail, - }) - render() - - fireEvent.contextMenu(screen.getByRole('alert')) - - await waitFor(() => expect(writeText).toHaveBeenCalledOnce()) - expect(writeText.mock.calls[0]?.[0]).toContain(detail) -}) diff --git a/src/components/layout/sysproxy-privilege-dialog.test.tsx b/src/components/layout/sysproxy-privilege-dialog.test.tsx deleted file mode 100644 index 64a24ef19..000000000 --- a/src/components/layout/sysproxy-privilege-dialog.test.tsx +++ /dev/null @@ -1,350 +0,0 @@ -// @vitest-environment jsdom -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' -import i18next from 'i18next' -import { initReactI18next } from 'react-i18next' -import { afterEach, beforeAll, expect, it, vi } from 'vitest' - -import type { PendingFailure } from '@/services/cmds' -import { getSnapshotNotices, hideNotice } from '@/services/notice-service' -import { clearServiceRequest, requestService } from '@/services/service-request' - -const reported = (key: string) => - getSnapshotNotices().some( - (notice) => - notice.i18n?.key === key || - Object.values(notice.i18n?.params ?? {}).some( - (param) => - typeof param === 'object' && - param !== null && - 'key' in param && - (param as { key?: unknown }).key === key, - ), - ) - -import { SysproxyPrivilegeDialog } from './sysproxy-privilege-dialog' - -const restartCore = vi.hoisted(() => vi.fn(() => Promise.resolve())) -const installService = vi.hoisted(() => vi.fn(() => Promise.resolve())) -const getRuntimeState = vi.hoisted(() => vi.fn()) -const getPendingFailures = vi.hoisted(() => vi.fn()) -const patchVergeConfig = vi.hoisted(() => vi.fn(() => Promise.resolve())) -const onFocusChanged = vi.hoisted(() => vi.fn(() => Promise.resolve(() => {}))) - -vi.mock('@/services/cmds', () => ({ - restartCore, - installService, - getRuntimeState, - getPendingFailures, - patchVergeConfig, -})) -vi.mock('@tauri-apps/api/window', () => ({ - getCurrentWindow: () => ({ onFocusChanged }), -})) - -let onSubscribed: (() => void) | undefined -vi.mock('@/services/events', () => ({ - subscribeVergeEvents: ( - _handlers: Record void>, - subscribed?: () => void, - ) => { - onSubscribed = subscribed - return () => {} - }, -})) - -const failure = ( - code: string, - operation: PendingFailure['operation'] = 'systemProxyEnable', -): PendingFailure => ({ - code, - detail: 'admin privileges required to modify system proxy', - operation, - sequence: 1, -}) - -beforeAll(async () => { - await i18next.use(initReactI18next).init({ - lng: 'en', - resources: { en: { translation: {} } }, - interpolation: { escapeValue: false }, - }) -}) - -afterEach(() => { - restartCore.mockClear() - restartCore.mockImplementation(() => Promise.resolve()) - installService.mockClear() - installService.mockImplementation(() => Promise.resolve()) - getRuntimeState.mockReset() - getRuntimeState.mockResolvedValue({ mode: 'Service', serviceUsable: true }) - getPendingFailures.mockReset() - patchVergeConfig.mockClear() - patchVergeConfig.mockImplementation(() => Promise.resolve()) - onSubscribed = undefined - clearServiceRequest() - cleanup() - getSnapshotNotices().forEach((notice) => hideNotice(notice.id)) -}) - -const showing = async ( - code: string, - operation: PendingFailure['operation'] = 'systemProxyEnable', -) => { - getPendingFailures.mockResolvedValue([failure(code, operation)]) - render() - onSubscribed?.() - await screen.findByRole('dialog') -} - -const clickPrimary = (name: string) => - fireEvent.click(screen.getByRole('button', { name })) - -it('offers to install the service when the write was refused', async () => { - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - - expect( - screen.getByRole('button', { - name: 'settings.sections.proxyControl.actions.installService', - }), - ).toBeTruthy() -}) - -it('offers only a restart when the service is already there', async () => { - await showing('SYSPROXY_SIDECAR_WHILE_SERVICE_READY') - - expect( - screen.getByRole('button', { - name: 'settings.sections.proxyControl.actions.switchToServiceMode', - }), - ).toBeTruthy() - expect( - screen.queryByRole('button', { - name: 'settings.sections.proxyControl.actions.installService', - }), - ).toBeNull() -}) - -it('installs the service, restarts into it, and makes the request again', async () => { - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - - clickPrimary('settings.sections.proxyControl.actions.installService') - - await waitFor(() => expect(installService).toHaveBeenCalledOnce()) - await waitFor(() => expect(restartCore).toHaveBeenCalledOnce()) - await waitFor(() => - expect(patchVergeConfig).toHaveBeenCalledWith({ - enable_system_proxy: true, - }), - ) - await waitFor(() => - expect( - reported( - 'settings.sections.proxyControl.messages.installedProxyRestored', - ), - ).toBe(true), - ) -}) - -it('replays the direction that was asked for, not always “on”', async () => { - await showing('SYSPROXY_PRIVILEGE_REQUIRED', 'systemProxyDisable') - - clickPrimary('settings.sections.proxyControl.actions.installService') - - await waitFor(() => - expect(patchVergeConfig).toHaveBeenCalledWith({ - enable_system_proxy: false, - }), - ) -}) - -it('changes nothing when nobody asked for a proxy state', async () => { - await showing('SYSPROXY_PRIVILEGE_REQUIRED', 'systemProxyRestore') - - clickPrimary('settings.sections.proxyControl.actions.installService') - - await waitFor(() => expect(restartCore).toHaveBeenCalledOnce()) - expect(patchVergeConfig).not.toHaveBeenCalled() - await waitFor(() => - expect( - reported('settings.sections.proxyControl.messages.installedCheckProxy'), - ).toBe(true), - ) -}) - -it('does not install again when only the restart is needed', async () => { - await showing('SYSPROXY_SIDECAR_WHILE_SERVICE_READY') - - clickPrimary('settings.sections.proxyControl.actions.switchToServiceMode') - - await waitFor(() => expect(restartCore).toHaveBeenCalledOnce()) - expect(installService).not.toHaveBeenCalled() -}) - -it('checks what actually happened rather than trusting the restart', async () => { - getRuntimeState.mockResolvedValue({ mode: 'Sidecar', serviceUsable: true }) - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - - clickPrimary('settings.sections.proxyControl.actions.installService') - - await waitFor(() => - expect( - reported( - 'settings.sections.proxyControl.messages.installedCoreNotOnService', - ), - ).toBe(true), - ) -}) - -const deferred = () => { - let resolve!: () => void - const promise = new Promise((r) => { - resolve = r - }) - return { promise, resolve } -} - -it('says which part of the wait it is in, rather than looking stuck', async () => { - const installing = deferred() - const restarting = deferred() - const applying = deferred() - installService.mockImplementation(() => installing.promise) - restartCore.mockImplementation(() => restarting.promise) - patchVergeConfig.mockImplementation(() => applying.promise) - - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - clickPrimary('settings.sections.proxyControl.actions.installService') - - await screen.findByText('layout.components.sysproxyPrivilege.installing') - installing.resolve() - - await screen.findByText('layout.components.sysproxyPrivilege.restarting') - restarting.resolve() - - await screen.findByText('layout.components.sysproxyPrivilege.applying') - applying.resolve() - - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) -}) - -it('does not claim to be installing when only a restart is needed', async () => { - const restarting = deferred() - restartCore.mockImplementation(() => restarting.promise) - - await showing('SYSPROXY_SIDECAR_WHILE_SERVICE_READY') - clickPrimary('settings.sections.proxyControl.actions.switchToServiceMode') - - await screen.findByText('layout.components.sysproxyPrivilege.restarting') - expect( - screen.queryByText('layout.components.sysproxyPrivilege.installing'), - ).toBeNull() - restarting.resolve() -}) - -it('offers no way out while the installer is holding a password prompt', async () => { - const installing = deferred() - installService.mockImplementation(() => installing.promise) - - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - clickPrimary('settings.sections.proxyControl.actions.installService') - - await screen.findByText('layout.components.sysproxyPrivilege.installing') - expect( - screen.queryByRole('button', { - name: 'layout.components.sysproxyPrivilege.later', - }), - ).toBeNull() - installing.resolve() -}) - -it('closes once the core is on the service, whatever the table still says', async () => { - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - - clickPrimary('settings.sections.proxyControl.actions.installService') - - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) -}) - -it('stays open when the core did not reach the service', async () => { - getRuntimeState.mockResolvedValue({ mode: 'Sidecar', serviceUsable: false }) - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - - clickPrimary('settings.sections.proxyControl.actions.installService') - - await waitFor(() => expect(restartCore).toHaveBeenCalledOnce()) - expect(screen.getByRole('dialog')).toBeTruthy() -}) - -it('does not go on to restart when the install itself failed', async () => { - installService.mockRejectedValue({ - code: 'SERVICE_INSTALL_FAILED', - detail: 'user cancelled', - }) - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - - clickPrimary('settings.sections.proxyControl.actions.installService') - - await waitFor(() => expect(installService).toHaveBeenCalledOnce()) - expect(restartCore).not.toHaveBeenCalled() - expect(getRuntimeState).not.toHaveBeenCalled() - expect(screen.getByRole('dialog')).toBeTruthy() -}) - -it('closes on “later” without touching the machine', async () => { - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - - clickPrimary('layout.components.sysproxyPrivilege.later') - - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) - expect(installService).not.toHaveBeenCalled() - expect(restartCore).not.toHaveBeenCalled() -}) - -it('answers a switch that asked outright, with no failure to go on', async () => { - getPendingFailures.mockResolvedValue([]) - render() - onSubscribed?.() - - act(() => - requestService({ - reason: 'tunNeedsService', - restore: { enable_tun_mode: true }, - }), - ) - - await screen.findByText('layout.components.sysproxyPrivilege.tunMessage') - clickPrimary('settings.sections.proxyControl.actions.installService') - - await waitFor(() => expect(installService).toHaveBeenCalledOnce()) - await waitFor(() => - expect(patchVergeConfig).toHaveBeenCalledWith({ enable_tun_mode: true }), - ) - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) -}) - -it('shows what the user is doing now over what the table still holds', async () => { - await showing('SYSPROXY_PRIVILEGE_REQUIRED') - - act(() => requestService({ reason: 'tunNeedsService' })) - - await screen.findByText('layout.components.sysproxyPrivilege.tunMessage') -}) - -it('changes nothing when a switch asked without saying what for', async () => { - getPendingFailures.mockResolvedValue([]) - render() - onSubscribed?.() - - act(() => requestService({ reason: 'tunNeedsService' })) - await screen.findByRole('dialog') - clickPrimary('settings.sections.proxyControl.actions.installService') - - await waitFor(() => expect(restartCore).toHaveBeenCalledOnce()) - expect(patchVergeConfig).not.toHaveBeenCalled() -}) diff --git a/src/components/proxy/proxy-empty-state-model.test.ts b/src/components/proxy/proxy-empty-state-model.test.ts deleted file mode 100644 index 075524036..000000000 --- a/src/components/proxy/proxy-empty-state-model.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { - resolveEmptyListReason, - resolveProxyListState, -} from './proxy-empty-state-model' - -const settled = { - isProfilesPending: false, - isProxyViewPending: false, - isRunningModePending: false, -} - -const subscribed = { - current: 'a', - items: [{ uid: 'a', type: 'remote' }], -} - -describe('resolveProxyListState', () => { - test('direct mode is its own outcome, not an empty list', () => { - const state = resolveProxyListState({ - ...settled, - mode: 'direct', - profiles: { current: undefined, items: [] }, - }) - - expect(state).toEqual({ kind: 'direct' }) - }) - - test('anything still loading outranks every verdict about emptiness', () => { - for (const pending of [ - { isProfilesPending: true }, - { isProxyViewPending: true }, - { isRunningModePending: true }, - ]) { - const state = resolveProxyListState({ - ...settled, - ...pending, - mode: 'rule', - profiles: { current: undefined, items: [] }, - }) - - expect(state).toEqual({ kind: 'loading' }) - } - }) - - test('no subscription at all is reported before anything about proxies', () => { - const state = resolveProxyListState({ - ...settled, - mode: 'rule', - profiles: { current: undefined, items: [{ uid: 'a', type: 'merge' }] }, - }) - - expect(state).toEqual({ kind: 'empty', reason: 'no-subscriptions' }) - }) - - test('a subscription that is not the active one is its own explanation', () => { - const state = resolveProxyListState({ - ...settled, - mode: 'rule', - profiles: { current: 'other', items: [{ uid: 'a', type: 'remote' }] }, - }) - - expect(state).toEqual({ kind: 'empty', reason: 'inactive-subscription' }) - }) - - test('with a usable subscription it defers to the list rather than guessing', () => { - // The point of the split: this function no longer has an opinion on whether the - // renderer will find anything, because the two opinions used to disagree. - for (const mode of ['rule', 'script', 'global']) { - expect( - resolveProxyListState({ ...settled, mode, profiles: subscribed }), - ).toEqual({ - kind: 'render', - }) - } - }) - - test('it asks nothing about the proxy view at all', () => { - // Guarding the shape, not just the behaviour: reintroducing a proxyView input here is - // how the second, divergent derivation came back. - const inputs = Object.keys({ - ...settled, - mode: 'rule', - profiles: subscribed, - }) - - expect(inputs).not.toContain('proxyView') - expect(inputs).not.toContain('isChainMode') - }) -}) - -describe('resolveEmptyListReason', () => { - test('a stopped core explains an empty list better than the list does', () => { - expect( - resolveEmptyListReason({ - runningMode: 'NotRunning', - isProxyViewError: false, - }), - ).toBe('core-unavailable') - }) - - test('a failed proxy fetch is reported as the core being unavailable', () => { - expect( - resolveEmptyListReason({ - runningMode: 'Sidecar', - isProxyViewError: true, - }), - ).toBe('core-unavailable') - }) - - test('a running core with a clean fetch means the profile simply has nothing', () => { - expect( - resolveEmptyListReason({ - runningMode: 'Sidecar', - isProxyViewError: false, - }), - ).toBe('no-proxy-info') - }) -}) diff --git a/src/components/proxy/use-render-list.test.ts b/src/components/proxy/use-render-list.test.ts deleted file mode 100644 index f788e543c..000000000 --- a/src/components/proxy/use-render-list.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { hasRenderableItems, type IRenderItem } from './use-render-list' - -const group = (hidden = false) => - ({ name: 'Proxies', hidden }) as unknown as IRenderItem['group'] - -const header = (hidden = false): IRenderItem => ({ - type: 0, - key: 'header', - group: group(hidden), -}) - -const member = (key: string): IRenderItem => ({ - type: 2, - key, - group: group(), -}) - -const memberColumn = (key: string): IRenderItem => ({ - type: 4, - key, - group: group(), -}) - -describe('hasRenderableItems', () => { - test('an empty list has nothing to show', () => { - expect(hasRenderableItems([])).toBe(false) - }) - - test('chain mode is answered by the chain list itself', () => { - // Rendered rows, not group types, determine whether chain mode is empty. - expect(hasRenderableItems([member('chain:a')])).toBe(true) - expect(hasRenderableItems([memberColumn('chain-col:a')])).toBe(true) - }) - - test('a visible group counts even before it is expanded', () => { - expect(hasRenderableItems([header()])).toBe(true) - }) - - test('hidden groups alone are not content', () => { - expect(hasRenderableItems([header(true), header(true)])).toBe(false) - }) - - test('one visible group among hidden ones is enough', () => { - expect(hasRenderableItems([header(true), header(), header(true)])).toBe( - true, - ) - }) - - test('rows inside a hidden but expanded group are still rows on screen', () => { - // Visible member rows mean the page is not empty even without a visible group header. - expect(hasRenderableItems([header(true), member('member:a')])).toBe(true) - }) -}) diff --git a/src/components/shared/proxy-control-switches.test.tsx b/src/components/shared/proxy-control-switches.test.tsx deleted file mode 100644 index aa89de350..000000000 --- a/src/components/shared/proxy-control-switches.test.tsx +++ /dev/null @@ -1,145 +0,0 @@ -// @vitest-environment jsdom -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' - -import { getSnapshotNotices, hideNotice } from '@/services/notice-service' -import { - clearServiceRequest, - getServiceRequest, -} from '@/services/service-request' - -import ProxyControlSwitches from './proxy-control-switches' - -const patchVerge = vi.hoisted(() => vi.fn(() => Promise.resolve())) -const mutateVerge = vi.hoisted(() => vi.fn()) -const isTunModeAvailable = vi.hoisted(() => ({ current: false })) -const tunEnabled = vi.hoisted(() => ({ current: false })) - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})) -vi.mock('@/hooks/use-verge', () => ({ - useVerge: () => ({ - verge: { enable_tun_mode: tunEnabled.current }, - mutateVerge, - patchVerge, - }), -})) -vi.mock('@/hooks/use-system-state', () => ({ - useSystemState: () => ({ - runState: { serviceUsable: false }, - isTunModeAvailable: isTunModeAvailable.current, - }), -})) -vi.mock('@/hooks/use-system-proxy-state', () => ({ - useSystemProxyState: () => ({ - indicator: false, - toggleSystemProxy: vi.fn(), - }), -})) -vi.mock('@/hooks/use-service-uninstaller', () => ({ - useServiceUninstaller: () => ({ uninstallServiceAndStartSidecar: vi.fn() }), -})) -vi.mock('@/components/setting/mods/sysproxy-viewer', () => ({ - SysproxyViewer: () => null, -})) -vi.mock('@/components/setting/mods/tun-viewer', () => ({ - TunViewer: () => null, -})) - -afterEach(() => { - patchVerge.mockClear() - mutateVerge.mockClear() - isTunModeAvailable.current = false - tunEnabled.current = false - clearServiceRequest() - getSnapshotNotices().forEach((notice) => hideNotice(notice.id)) - cleanup() -}) - -const renderTun = () => - render( - , - ) - -const theSwitch = () => screen.getByRole('switch') as HTMLInputElement -const flip = () => fireEvent.click(theSwitch()) - -it('asks for the service when TUN cannot work, saying what it was for', async () => { - renderTun() - - flip() - - await waitFor(() => - expect(getServiceRequest()).toEqual({ - reason: 'tunNeedsService', - restore: { enable_tun_mode: true }, - }), - ) - expect(patchVerge).not.toHaveBeenCalled() -}) - -it('leaves the switch where it was until something can carry the request', async () => { - renderTun() - - flip() - - await waitFor(() => expect(theSwitch().checked).toBe(false)) -}) - -it('patches directly when the service can already carry it', async () => { - isTunModeAvailable.current = true - renderTun() - - flip() - - await waitFor(() => - expect(patchVerge).toHaveBeenCalledWith({ enable_tun_mode: true }), - ) - expect(getServiceRequest()).toBeNull() -}) - -it('no longer offers a separate install button', () => { - renderTun() - - expect( - screen.queryByLabelText( - 'settings.sections.proxyControl.actions.installService', - ), - ).toBeNull() -}) - -it('does not ask for a service in order to turn TUN off', async () => { - tunEnabled.current = true - renderTun() - - flip() - - await waitFor(() => - expect(patchVerge).toHaveBeenCalledWith({ enable_tun_mode: false }), - ) - expect(getServiceRequest()).toBeNull() -}) - -it('says nothing beside the dialog it just opened', async () => { - const onError = vi.fn() - render( - , - ) - - flip() - - await waitFor(() => expect(getServiceRequest()).not.toBeNull()) - await waitFor(() => expect(theSwitch().checked).toBe(false)) - expect(onError).not.toHaveBeenCalled() - expect(getSnapshotNotices()).toEqual([]) -}) diff --git a/src/hooks/use-record-selection.test.tsx b/src/hooks/use-record-selection.test.tsx deleted file mode 100644 index 2c5bb82c2..000000000 --- a/src/hooks/use-record-selection.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// @vitest-environment jsdom -import { renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { useForgetSelection, useRecordSelection } from './use-record-selection' - -const recordSelectedNode = vi.hoisted(() => vi.fn(() => Promise.resolve())) -const forgetSelectedNode = vi.hoisted(() => vi.fn(() => Promise.resolve())) - -vi.mock('@/services/cmds', () => ({ forgetSelectedNode, recordSelectedNode })) - -const record = (groupName: string, proxyName: string) => { - const { result } = renderHook(() => useRecordSelection()) - result.current(groupName, proxyName) -} - -beforeEach(() => { - recordSelectedNode.mockClear() - forgetSelectedNode.mockClear() -}) - -describe('useForgetSelection', () => { - it('sends only the group name', () => { - const { result } = renderHook(() => useForgetSelection()) - - result.current('Proxy') - - expect(forgetSelectedNode).toHaveBeenCalledWith('Proxy') - }) - - it('resolves after the clear request finishes', async () => { - let resolveForget!: () => void - forgetSelectedNode.mockImplementationOnce( - () => new Promise((resolve) => (resolveForget = resolve)), - ) - const { result } = renderHook(() => useForgetSelection()) - - const forget = result.current('Proxy') - let settled = false - forget.then(() => { - settled = true - }) - - await Promise.resolve() - expect(settled).toBe(false) - - resolveForget() - await forget - expect(settled).toBe(true) - }) - - it('reports a failed clear without throwing at the caller', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - forgetSelectedNode.mockRejectedValueOnce(new Error('no profile')) - const { result } = renderHook(() => useForgetSelection()) - - expect(() => result.current('Proxy')).not.toThrow() - - await vi.waitFor(() => expect(consoleError).toHaveBeenCalled()) - consoleError.mockRestore() - }) -}) - -describe('useRecordSelection', () => { - it('sends only the group and the node', () => { - record('Proxy', 'Node A') - - expect(recordSelectedNode).toHaveBeenCalledWith('Proxy', 'Node A') - }) - - it('does not derive anything from a rendered selection list', () => { - // Pair-wise writes let the backend merge concurrent choices against fresh state. - record('Proxy', 'Node A') - record('Fallback', 'Node C') - - expect(recordSelectedNode).toHaveBeenNthCalledWith(1, 'Proxy', 'Node A') - expect(recordSelectedNode).toHaveBeenNthCalledWith(2, 'Fallback', 'Node C') - }) - - it('is stable across renders, so callers may hold it in a dependency array', () => { - const { result, rerender } = renderHook(() => useRecordSelection()) - const first = result.current - - rerender() - - expect(result.current).toBe(first) - }) - - it('reports a failed write without throwing at the caller', async () => { - // Persistence failure cannot roll back a core selection that already succeeded. - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - recordSelectedNode.mockRejectedValueOnce(new Error('no profile')) - - expect(() => record('Proxy', 'Node A')).not.toThrow() - - await vi.waitFor(() => expect(consoleError).toHaveBeenCalled()) - }) -}) diff --git a/src/hooks/use-system-proxy-state.test.tsx b/src/hooks/use-system-proxy-state.test.tsx deleted file mode 100644 index 9f06bbbad..000000000 --- a/src/hooks/use-system-proxy-state.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -// @vitest-environment jsdom -import { act, cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -import { useSystemProxyState } from './use-system-proxy-state' - -const patchVergeConfig = vi.hoisted(() => vi.fn()) -const mutateVerge = vi.hoisted(() => vi.fn()) -const getCacheData = vi.hoisted(() => vi.fn()) -const revalidateQueries = vi.hoisted(() => vi.fn()) - -const CACHED: IVergeConfig = { enable_system_proxy: false } as IVergeConfig - -vi.mock('@/hooks/use-verge', () => ({ - useVerge: () => ({ verge: CACHED, mutateVerge }), -})) -vi.mock('@/hooks/use-displayed-mixed-port', () => ({ - useDisplayedMixedPort: () => 7897, -})) -vi.mock('@/providers/app-data-context', () => ({ - useSystemData: () => ({ sysproxy: undefined }), -})) -vi.mock('@/services/cmds', () => ({ - getAutotemProxy: vi.fn(), - getEmbeddedServerPort: vi.fn(), - patchVergeConfig, -})) -vi.mock('tauri-plugin-mihomo-api', () => ({ - closeAllConnections: vi.fn(() => Promise.resolve()), -})) -vi.mock('@/services/query-client', () => ({ - useQuery: () => ({ data: undefined }), - getCacheData, - revalidateQueries, -})) - -beforeEach(() => { - patchVergeConfig.mockReset() - mutateVerge.mockReset() - getCacheData.mockReset() - getCacheData.mockReturnValue(CACHED) - revalidateQueries.mockReset() - revalidateQueries.mockResolvedValue([]) -}) - -afterEach(() => { - cleanup() -}) - -const lastWrite = () => { - const call = mutateVerge.mock.calls[mutateVerge.mock.calls.length - 1]?.[0] - return typeof call === 'function' ? call(CACHED) : call -} - -it('puts the cache back when the toggle fails', async () => { - patchVergeConfig.mockRejectedValue({ - code: 'SYSPROXY_PRIVILEGE_REQUIRED', - detail: 'refused', - }) - const { result } = renderHook(() => useSystemProxyState()) - - await act(async () => { - await expect(result.current.toggleSystemProxy(true)).rejects.toBeTruthy() - }) - - expect(lastWrite()).toMatchObject({ enable_system_proxy: false }) -}) - -it('falls back to what the last successful click reached, not to where the run began', async () => { - patchVergeConfig.mockResolvedValueOnce(undefined).mockRejectedValueOnce({ - code: 'SYSPROXY_PRIVILEGE_REQUIRED', - detail: 'refused', - }) - getCacheData.mockReturnValue({ enable_system_proxy: true } as IVergeConfig) - const { result } = renderHook(() => useSystemProxyState()) - - await act(async () => { - const inFlight = result.current.toggleSystemProxy(false) - void result.current.toggleSystemProxy(true) - await expect(inFlight).rejects.toBeTruthy() - }) - - expect(lastWrite()).toMatchObject({ enable_system_proxy: false }) -}) - -it('lets a revalidation failure surface when the toggle itself worked', async () => { - patchVergeConfig.mockResolvedValue(undefined) - revalidateQueries.mockRejectedValue(new Error('network gone')) - const { result } = renderHook(() => useSystemProxyState()) - - await act(async () => { - await expect(result.current.toggleSystemProxy(true)).rejects.toThrow( - 'network gone', - ) - }) -}) - -it('reports the failure the backend classified, not a revalidation that failed after it', async () => { - patchVergeConfig.mockRejectedValue({ - code: 'SYSPROXY_PRIVILEGE_REQUIRED', - detail: 'refused', - }) - revalidateQueries.mockRejectedValue(new Error('network gone')) - const { result } = renderHook(() => useSystemProxyState()) - - await act(async () => { - await expect(result.current.toggleSystemProxy(true)).rejects.toMatchObject({ - code: 'SYSPROXY_PRIVILEGE_REQUIRED', - }) - }) -}) - -it('revalidates the config it wrote to, not only the two proxy reads', async () => { - patchVergeConfig.mockResolvedValue(undefined) - const { result } = renderHook(() => useSystemProxyState()) - - await act(async () => { - await result.current.toggleSystemProxy(true) - }) - - expect(revalidateQueries).toHaveBeenCalledWith([ - ['getVergeConfig'], - ['getSystemProxy'], - ['getAutotemProxy'], - ]) -}) - -it('drops a click queued behind one that failed', async () => { - let rejectFirst: ((reason: unknown) => void) | undefined - patchVergeConfig.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectFirst = reject - }), - ) - const { result } = renderHook(() => useSystemProxyState()) - - let inFlight: Promise = Promise.resolve() - await act(async () => { - inFlight = result.current.toggleSystemProxy(true) - void result.current.toggleSystemProxy(false) - rejectFirst?.({ code: 'SYSPROXY_PRIVILEGE_REQUIRED', detail: 'refused' }) - await expect(inFlight).rejects.toBeTruthy() - }) - - expect(patchVergeConfig).toHaveBeenCalledTimes(1) -}) diff --git a/src/hooks/use-update.test.tsx b/src/hooks/use-update.test.tsx deleted file mode 100644 index 5a24877f8..000000000 --- a/src/hooks/use-update.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -// @vitest-environment jsdom -import { act, renderHook, waitFor } from '@testing-library/react' -import { beforeEach, expect, test, vi } from 'vitest' - -import { setPreloadConfig } from '@/services/preload' - -import { useUpdate } from './use-update' - -const check = vi.hoisted(() => vi.fn()) - -vi.mock('@tauri-apps/plugin-updater', () => ({ check })) - -beforeEach(() => { - localStorage.clear() - setPreloadConfig({ auto_check_update: false } as IVergeConfig) - check.mockReset() -}) - -test('manual update check works when automatic checks are disabled', async () => { - const availableUpdate = { version: '9.9.9' } - check.mockResolvedValueOnce(availableUpdate) - const { result } = renderHook(() => useUpdate()) - - let checked - await act(async () => { - checked = await result.current.checkUpdate() - }) - - expect(check).toHaveBeenCalledOnce() - expect(checked).toEqual({ data: availableUpdate }) - await waitFor(() => expect(result.current.lastCheckUpdate).not.toBeNull()) -}) diff --git a/src/pages/_layout/hooks/use-pending-failures.test.tsx b/src/pages/_layout/hooks/use-pending-failures.test.tsx deleted file mode 100644 index fdc25473c..000000000 --- a/src/pages/_layout/hooks/use-pending-failures.test.tsx +++ /dev/null @@ -1,228 +0,0 @@ -// @vitest-environment jsdom -import { act, cleanup, renderHook, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -import type { PendingFailure } from '@/services/cmds' -import { getSnapshotNotices, hideNotice } from '@/services/notice-service' - -import { useDialogFailure, usePendingFailures } from './use-pending-failures' - -const getPendingFailures = vi.hoisted(() => vi.fn()) -const onFocusChanged = vi.hoisted(() => vi.fn()) -const isVisible = vi.hoisted(() => vi.fn()) -const isMinimized = vi.hoisted(() => vi.fn()) - -vi.mock('@/services/cmds', () => ({ getPendingFailures })) -vi.mock('@tauri-apps/api/window', () => ({ - getCurrentWindow: () => ({ onFocusChanged, isVisible, isMinimized }), -})) - -let table: PendingFailure[] = [] -let onSubscribed: (() => void) | undefined -let onTableChanged: (() => void) | undefined -let focusListener: ((event: { payload: boolean }) => void) | undefined - -vi.mock('@/services/events', () => ({ - subscribeVergeEvents: ( - handlers: Record void>, - subscribed?: () => void, - ) => { - onTableChanged = handlers['verge://pending-failures-changed'] - onSubscribed = subscribed - return () => {} - }, -})) - -const failure = (sequence: number, detail: string): PendingFailure => ({ - code: 'SYSPROXY_GUARD_STOPPED', - detail, - operation: 'systemProxyGuard', - sequence, -}) - -const dialogFailure = (sequence: number): PendingFailure => ({ - code: 'SYSPROXY_PRIVILEGE_REQUIRED', - detail: 'refused', - operation: 'systemProxyEnable', - sequence, -}) - -const shownDetails = () => - getSnapshotNotices().map((notice) => notice.i18n?.params?.message) - -beforeEach(() => { - getPendingFailures.mockClear() - onFocusChanged.mockClear() - isVisible.mockReset() - isVisible.mockResolvedValue(true) - isMinimized.mockReset() - isMinimized.mockResolvedValue(false) - table = [] - onSubscribed = undefined - onTableChanged = undefined - focusListener = undefined - getPendingFailures.mockImplementation(() => Promise.resolve(table)) - onFocusChanged.mockImplementation( - (listener: (event: { payload: boolean }) => void) => { - focusListener = listener - return Promise.resolve(() => {}) - }, - ) -}) - -afterEach(() => { - cleanup() - getSnapshotNotices().forEach((notice) => hideNotice(notice.id)) -}) - -it('shows a failure that was recorded before the listeners were live', async () => { - table = [failure(1, 'refused')] - renderHook(() => usePendingFailures()) - - onSubscribed?.() - - await waitFor(() => expect(shownDetails()).toEqual(['refused'])) -}) - -it('shows a failure recorded after the window was already up', async () => { - renderHook(() => usePendingFailures()) - onSubscribed?.() - await waitFor(() => expect(getPendingFailures).toHaveBeenCalled()) - - table = [failure(1, 'refused')] - onTableChanged?.() - - await waitFor(() => expect(shownDetails()).toEqual(['refused'])) -}) - -it('does not show the same failure again when asked again', async () => { - table = [failure(1, 'refused')] - renderHook(() => usePendingFailures()) - - onSubscribed?.() - await waitFor(() => expect(shownDetails()).toEqual(['refused'])) - onTableChanged?.() - focusListener?.({ payload: true }) - - await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(3)) - expect(shownDetails()).toEqual(['refused']) -}) - -it('shows the same code again when it has failed again', async () => { - table = [failure(1, 'refused')] - renderHook(() => usePendingFailures()) - onSubscribed?.() - await waitFor(() => expect(shownDetails()).toEqual(['refused'])) - - table = [failure(2, 'refused again')] - onTableChanged?.() - - await waitFor(() => expect(shownDetails()).toEqual(['refused again'])) -}) - -it('reads when the window takes focus', async () => { - renderHook(() => usePendingFailures()) - onSubscribed?.() - await waitFor(() => expect(getPendingFailures).toHaveBeenCalled()) - - table = [failure(1, 'refused')] - focusListener?.({ payload: true }) - - await waitFor(() => expect(shownDetails()).toEqual(['refused'])) -}) - -it('keeps a failure pending while the window is hidden', async () => { - isVisible.mockResolvedValue(false) - table = [failure(1, 'refused')] - renderHook(() => usePendingFailures()) - - onSubscribed?.() - await waitFor(() => expect(isVisible).toHaveBeenCalled()) - expect(shownDetails()).toEqual([]) - - isVisible.mockResolvedValue(true) - focusListener?.({ payload: true }) - - await waitFor(() => expect(shownDetails()).toEqual(['refused'])) -}) - -it('keeps a failure pending while the window is minimised', async () => { - isMinimized.mockResolvedValue(true) - table = [failure(1, 'refused')] - renderHook(() => usePendingFailures()) - - onSubscribed?.() - await waitFor(() => expect(isMinimized).toHaveBeenCalled()) - expect(shownDetails()).toEqual([]) - - isMinimized.mockResolvedValue(false) - focusListener?.({ payload: true }) - - await waitFor(() => expect(shownDetails()).toEqual(['refused'])) -}) - -it('keeps a failure pending when the window cannot say what it is doing', async () => { - isVisible.mockRejectedValue(new Error('no window')) - table = [failure(1, 'refused')] - renderHook(() => usePendingFailures()) - - onSubscribed?.() - await waitFor(() => expect(isVisible).toHaveBeenCalled()) - expect(shownDetails()).toEqual([]) -}) - -it('ignores losing focus', async () => { - renderHook(() => usePendingFailures()) - onSubscribed?.() - await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(1)) - - focusListener?.({ payload: false }) - - expect(getPendingFailures).toHaveBeenCalledTimes(1) -}) - -it('leaves a failure the dialog owns out of the toasts', async () => { - table = [dialogFailure(1), failure(2, 'guard gave up')] - renderHook(() => usePendingFailures()) - onSubscribed?.() - - await waitFor(() => expect(shownDetails()).toEqual(['guard gave up'])) -}) - -it('offers the dialog the failure it owns, and nothing else', async () => { - table = [failure(1, 'guard gave up'), dialogFailure(2)] - const { result } = renderHook(() => useDialogFailure()) - onSubscribed?.() - - await waitFor(() => - expect(result.current.failure?.code).toBe('SYSPROXY_PRIVILEGE_REQUIRED'), - ) -}) - -it('keeps a dismissed dialog closed while the table still holds the failure', async () => { - table = [dialogFailure(1)] - const { result } = renderHook(() => useDialogFailure()) - onSubscribed?.() - await waitFor(() => expect(result.current.failure).not.toBeNull()) - - act(() => result.current.dismiss()) - expect(result.current.failure).toBeNull() - - focusListener?.({ payload: true }) - await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(2)) - expect(result.current.failure).toBeNull() -}) - -it('opens again when the same code fails again', async () => { - table = [dialogFailure(1)] - const { result } = renderHook(() => useDialogFailure()) - onSubscribed?.() - await waitFor(() => expect(result.current.failure).not.toBeNull()) - act(() => result.current.dismiss()) - expect(result.current.failure).toBeNull() - - table = [dialogFailure(2)] - onTableChanged?.() - - await waitFor(() => expect(result.current.failure?.sequence).toBe(2)) -}) diff --git a/src/providers/app-data-provider.test.tsx b/src/providers/app-data-provider.test.tsx deleted file mode 100644 index 3c04a14da..000000000 --- a/src/providers/app-data-provider.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// @vitest-environment jsdom -import { render, screen } from '@testing-library/react' -import { afterEach, beforeEach, expect, test, vi } from 'vitest' - -// Stub only the Tauri boundary so the real provider tree still renders. -vi.mock('@tauri-apps/api/core', () => ({ - invoke: vi.fn(async () => undefined), - Channel: class {}, -})) -vi.mock('@tauri-apps/api/event', () => ({ - listen: vi.fn(async () => () => {}), - TauriEvent: {}, -})) - -import { AppDataProvider } from './app-data-provider' - -beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }) -}) - -afterEach(() => { - vi.useRealTimers() -}) - -// Rendering catches a provider reading its own context, which static checks cannot detect. -test('AppDataProvider mounts and renders its children', () => { - render( - - child rendered - , - ) - - expect(screen.getByText('child rendered')).toBeDefined() -}) - -test('AppDataProvider does not consume a context it provides', () => { - const errors: unknown[] = [] - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation((...args) => { - errors.push(args) - }) - - expect(() => - render( - - child rendered - , - ), - ).not.toThrow() - - const reported = errors.flat().join(' ') - expect(reported).not.toContain('must be used within AppDataProvider') - - consoleError.mockRestore() -}) diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts index 81b3f7230..a4d7c92fd 100644 --- a/src/services/delay.test.ts +++ b/src/services/delay.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -// Stub only the delay API; these tests cover group-settle notification granularity. vi.mock('tauri-plugin-mihomo-api', () => ({ delayProxyByName: vi.fn(async () => ({ delay: 120 })), healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })), @@ -22,7 +21,7 @@ const node = (name: string) => }, }) as unknown as ResolvedProxyMember -const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0)) +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) let settles = 0 let unsubscribe: () => void @@ -34,90 +33,26 @@ beforeEach(() => { }) }) -afterEach(() => { - unsubscribe() -}) +afterEach(() => unsubscribe()) -describe('group settle notifications', () => { - test('a single test settles the group once', async () => { - // A single-proxy retest must invalidate delay sorting. - await delayManager.checkDelay(node('a') as never, 'g', 5000) - await nextFrame() - - expect(settles).toBe(1) - }) - - test('a batch settles the group once, however many proxies it covered', async () => { - // A batch announces once to avoid reordering rows as individual results arrive. - const proxies = Array.from({ length: 20 }, (_, index) => node(`n${index}`)) - - await delayManager.checkListDelay(proxies as never, 'g', 5000, 4) - await nextFrame() - - expect(settles).toBe(1) - }) - - test('an unsubscribed listener stops being called', async () => { - unsubscribe() - - await delayManager.checkDelay(node('a') as never, 'g', 5000) - await nextFrame() - - expect(settles).toBe(0) - }) - - test('two listeners on one group both hear it', async () => { - // Multiple views must not replace one another's group subscriptions. - let second = 0 - const stop = delayManager.addGroupListener('g', () => { - second += 1 - }) - - await delayManager.checkDelay(node('a') as never, 'g', 5000) - await nextFrame() - - expect(settles).toBe(1) - expect(second).toBe(1) - stop() - }) - - test('a single test inside a running batch waits for the batch', async () => { - // Nested batches must wait for the outermost settle before announcing. +describe('group delay completion', () => { + test('notifies once after a batch settles', async () => { const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`)) - const batch = delayManager.checkListDelay(proxies as never, 'g', 5000, 2) - await delayManager.checkDelay(node('single') as never, 'g', 5000) - await nextFrame() - expect(settles).toBe(0) + await delayManager.checkListDelay(proxies as never, 'g', 5000, 2) + await flush() - await batch - await nextFrame() expect(settles).toBe(1) }) - test('unsubscribing one listener leaves the others subscribed', async () => { - let second = 0 - const stop = delayManager.addGroupListener('g', () => { - second += 1 - }) - - unsubscribe() - await delayManager.checkDelay(node('a') as never, 'g', 5000) - await nextFrame() - - expect(settles).toBe(0) - expect(second).toBe(1) - stop() - }) - - test('another group is not disturbed', async () => { + test('notifies only listeners for the completed group', async () => { let other = 0 const stop = delayManager.addGroupListener('other', () => { other += 1 }) await delayManager.checkDelay(node('a') as never, 'g', 5000) - await nextFrame() + await flush() expect(settles).toBe(1) expect(other).toBe(0) diff --git a/src/services/events.test.ts b/src/services/events.test.ts index efaa4ccae..f14294481 100644 --- a/src/services/events.test.ts +++ b/src/services/events.test.ts @@ -6,22 +6,6 @@ const listen = vi.hoisted(() => vi.fn()) vi.mock('@tauri-apps/api/event', () => ({ listen })) -/** A `listen` whose registrations only resolve when the test says so. */ -const deferredListen = () => { - const resolvers: Array<() => void> = [] - listen.mockImplementation( - () => - new Promise((resolve) => { - resolvers.push(() => resolve(() => {})) - }), - ) - return { - registrations: resolvers, - settleAll: () => resolvers.forEach((resolve) => resolve()), - } -} - -/** Let every already-resolved promise in the chain run. */ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) beforeEach(() => { @@ -29,8 +13,14 @@ beforeEach(() => { }) describe('subscribeVergeEvents', () => { - it('reports subscribed only once every listener is live', async () => { - const { registrations, settleAll } = deferredListen() + it('reports readiness only after every listener is registered', async () => { + const registrations: Array<() => void> = [] + listen.mockImplementation( + () => + new Promise((resolve) => { + registrations.push(() => resolve(() => {})) + }), + ) const onSubscribed = vi.fn() subscribeVergeEvents( @@ -41,48 +31,16 @@ describe('subscribeVergeEvents', () => { onSubscribed, ) - expect(registrations).toHaveLength(2) registrations[0]?.() await flush() expect(onSubscribed).not.toHaveBeenCalled() - settleAll() + registrations[1]?.() await flush() - expect(onSubscribed).toHaveBeenCalledTimes(1) + expect(onSubscribed).toHaveBeenCalledOnce() }) - it('does not report subscribed after teardown', async () => { - // Registration outliving the caller is the whole reason this is tracked here; resyncing - // for a subscription that no longer exists would fetch into a cache nobody reads. - const { settleAll } = deferredListen() - const onSubscribed = vi.fn() - - const teardown = subscribeVergeEvents( - { 'verge://run-state-changed': () => {} }, - onSubscribed, - ) - teardown() - - settleAll() - await flush() - expect(onSubscribed).not.toHaveBeenCalled() - }) - - it('still reports subscribed when a listener fails to register', async () => { - // A resync is most needed when a subscription is missing, not least. - listen.mockRejectedValue(new Error('no such event')) - vi.spyOn(console, 'error').mockImplementation(() => {}) - const onSubscribed = vi.fn() - - subscribeVergeEvents( - { 'verge://run-state-changed': () => {} }, - onSubscribed, - ) - - await vi.waitFor(() => expect(onSubscribed).toHaveBeenCalledTimes(1)) - }) - - it('delivers event payloads to the matching handler', async () => { + it('delivers a payload to its matching handler', () => { const handlers = new Map void>() listen.mockImplementation( (name: string, handler: (event: unknown) => void) => { @@ -91,16 +49,10 @@ describe('subscribeVergeEvents', () => { }, ) const onRunState = vi.fn() - const onNotice = vi.fn() - - subscribeVergeEvents({ - 'verge://run-state-changed': onRunState, - 'verge://notice-message': onNotice, - }) + subscribeVergeEvents({ 'verge://run-state-changed': onRunState }) handlers.get('verge://run-state-changed')?.({ mode: 'Sidecar' }) expect(onRunState).toHaveBeenCalledWith({ mode: 'Sidecar' }) - expect(onNotice).not.toHaveBeenCalled() }) }) diff --git a/src/services/notice-service.test.ts b/src/services/notice-service.test.ts deleted file mode 100644 index 531ca4706..000000000 --- a/src/services/notice-service.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' - -import { - boundNoticeText, - errorDetail, - getSnapshotNotices, - hideNotice, - showNotice, -} from '@/services/notice-service' - -/** Return the last notice text that is actually rendered. */ -function renderedDetail(): string | undefined { - const notices = getSnapshotNotices() - const notice = notices[notices.length - 1] - if (typeof notice?.message === 'string') return notice.message - const slot = notice?.i18n?.params?.message - return typeof slot === 'string' ? slot : undefined -} - -/** Return the rendered failure notice fields. */ -function renderedExplanation() { - const notices = getSnapshotNotices() - const i18n = notices[notices.length - 1]?.i18n - const readString = (value: unknown) => - typeof value === 'string' ? value : undefined - - return { - outerKey: i18n?.key, - explanationKey: readString(i18n?.params?.prefixKey), - prefix: readString(i18n?.params?.prefix), - detail: readString(i18n?.params?.message), - } -} - -afterEach(() => { - getSnapshotNotices().forEach((notice) => hideNotice(notice.id)) -}) - -describe('command failures reaching a notice', () => { - it('keeps the detail when the failure is the message', () => { - showNotice.error({ - code: 'CORE_START_FAILED', - detail: 'connection refused', - }) - - expect(renderedDetail()).toContain('connection refused') - }) - - it('keeps the detail when a translation key comes first', () => { - showNotice.error('settings.feedback.errors.clash.startFailed', { - code: 'CORE_START_FAILED', - detail: 'connection refused', - }) - - expect(renderedDetail()).toContain('connection refused') - }) - - it('keeps the detail when real translation params come first', () => { - showNotice.error( - 'settings.feedback.errors.clash.startFailed', - { name: 'mihomo' }, - { code: 'CORE_START_FAILED', detail: 'connection refused' }, - ) - - expect(renderedDetail()).toContain('connection refused') - }) - - it('keeps the detail for a failure that carries no code', () => { - showNotice.error('settings.feedback.errors.clash.startFailed', { - detail: 'disk full', - }) - - expect(renderedDetail()).toContain('disk full') - }) -}) - -describe('system proxy failures the user can be told about', () => { - it('explains a fallback to a direct connection', () => { - showNotice.error({ - code: 'SYSPROXY_DIRECT_FALLBACK', - detail: 'service could not set the proxy', - }) - - expect(renderedExplanation()).toEqual({ - outerKey: 'shared.feedback.notices.prefixedRaw', - explanationKey: 'settings.feedback.errors.sysproxy.directFallback', - prefix: undefined, - detail: 'service could not set the proxy', - }) - }) - - it('explains a guard that gave up', () => { - showNotice.error({ - code: 'SYSPROXY_GUARD_STOPPED', - detail: 'the proxy could not be reasserted', - }) - - expect(renderedExplanation()).toEqual({ - outerKey: 'shared.feedback.notices.prefixedRaw', - explanationKey: 'settings.feedback.errors.sysproxy.guardStopped', - prefix: undefined, - detail: 'the proxy could not be reasserted', - }) - }) - - it('says nothing at all about a failure a dialog is already reporting', () => { - showNotice.error({ - code: 'SYSPROXY_PRIVILEGE_REQUIRED', - detail: 'admin privileges required to modify system proxy', - }) - showNotice.error({ - code: 'SYSPROXY_SIDECAR_WHILE_SERVICE_READY', - detail: 'the core is still a sidecar', - }) - - expect(getSnapshotNotices()).toEqual([]) - }) - - it('explains the cause even when the caller names the operation', () => { - showNotice.error( - 'layout.components.serviceMigration.errors.restartFailed', - { - code: 'SYSPROXY_GUARD_STOPPED', - detail: 'the proxy could not be reasserted', - }, - ) - - expect(renderedExplanation()).toEqual({ - outerKey: 'shared.feedback.notices.prefixedRaw', - explanationKey: 'settings.feedback.errors.sysproxy.guardStopped', - prefix: undefined, - detail: 'the proxy could not be reasserted', - }) - }) - - it('leaves the caller key in place for a code nothing explains', () => { - showNotice.error( - 'layout.components.serviceMigration.errors.restartFailed', - { - code: 'SOME_UNMAPPED_CODE', - detail: 'connection refused', - }, - ) - - expect(renderedExplanation()).toEqual({ - outerKey: 'shared.feedback.notices.prefixedRaw', - explanationKey: 'layout.components.serviceMigration.errors.restartFailed', - prefix: undefined, - detail: 'connection refused', - }) - }) - - it('leaves literal caller text in place, mapped code or not', () => { - showNotice.error('Could not reconnect the profile', { - code: 'SYSPROXY_GUARD_STOPPED', - detail: 'the proxy could not be reasserted', - }) - - expect(renderedExplanation()).toEqual({ - outerKey: 'shared.feedback.notices.prefixedRaw', - explanationKey: undefined, - prefix: 'Could not reconnect the profile', - detail: 'the proxy could not be reasserted', - }) - }) - - it('explains a guard that gave up', () => { - showNotice.error({ - code: 'SYSPROXY_GUARD_STOPPED', - detail: 'service refused three times running', - }) - - expect(renderedExplanation()).toEqual({ - outerKey: 'shared.feedback.notices.prefixedRaw', - explanationKey: 'settings.feedback.errors.sysproxy.guardStopped', - prefix: undefined, - detail: 'service refused three times running', - }) - }) - - it('falls back to the generic message for a code nothing maps', () => { - showNotice.error({ code: 'SYSPROXY_NOT_A_REAL_CODE', detail: 'boom' }) - - expect(renderedExplanation()).toEqual({ - outerKey: 'shared.feedback.notices.prefixedRaw', - explanationKey: 'shared.feedback.errors.operationFailed', - prefix: undefined, - detail: 'boom', - }) - }) -}) - -describe('boundNoticeText', () => { - it('leaves text that fits exactly at the limit alone', () => { - const exact = 'x'.repeat(500) - - expect(boundNoticeText(exact)).toBe(exact) - }) - - it('marks text it had to shorten', () => { - expect(boundNoticeText('x'.repeat(501))).toBe(`${'x'.repeat(500)}…`) - }) - - it('does not cut a surrogate pair in half', () => { - expect(boundNoticeText('🙂'.repeat(600))).toBe(`${'🙂'.repeat(500)}…`) - }) - - it('does not mistake a long message for a translation key', () => { - const message = `a.${'b'.repeat(400)}` - showNotice.error(message) - - expect(renderedExplanation()).toEqual({ - outerKey: 'shared.feedback.notices.raw', - explanationKey: undefined, - prefix: undefined, - detail: message, - }) - }) - - it('does not leave a space before the ellipsis', () => { - expect(boundNoticeText(`${'x'.repeat(498)} yyy`)).toBe( - `${'x'.repeat(498)}…`, - ) - }) -}) - -describe('errorDetail', () => { - it('reads the detail out of a command failure', () => { - expect(errorDetail({ code: 'X', detail: 'legacy TLS not supported' })).toBe( - 'legacy TLS not supported', - ) - }) - - it('passes plain strings through untouched', () => { - expect(errorDetail('something broke')).toBe('something broke') - }) - - it('still reads an Error, which not every failure path replaces', () => { - expect(errorDetail(new Error('boom'))).toBe('boom') - }) -}) diff --git a/src/utils/delay.test.ts b/src/utils/delay.test.ts index 37a2a5471..95453ec01 100644 --- a/src/utils/delay.test.ts +++ b/src/utils/delay.test.ts @@ -2,80 +2,41 @@ import { describe, expect, test } from 'vitest' import { classifyDelay, compareByDelay, DEFAULT_DELAY_TIMEOUT } from './delay' -describe('classifyDelay', () => { - test('an ordinary latency is a measurement', () => { - expect(classifyDelay(120)).toBe('measured') - expect(classifyDelay(DEFAULT_DELAY_TIMEOUT - 1)).toBe('measured') - }) - - test('the core reports an in-flight test as -2', () => { - expect(classifyDelay(-2)).toBe('testing') - }) - - test('any other negative means we have never measured it', () => { - expect(classifyDelay(-1)).toBe('untested') - expect(classifyDelay(-99)).toBe('untested') - }) - - test('zero and anything past the timeout are timeouts, not fast results', () => { - // Zero is the trap: sorted as a number it would come first. - expect(classifyDelay(0)).toBe('timeout') - expect(classifyDelay(DEFAULT_DELAY_TIMEOUT)).toBe('timeout') - }) - - test('an implausible value is an error rather than a very slow node', () => { - expect(classifyDelay(1e5 + 1)).toBe('error') - }) - - test('a non-number is treated as never measured', () => { - expect(classifyDelay(Number.NaN)).toBe('untested') - expect(classifyDelay(Number.POSITIVE_INFINITY)).toBe('untested') - }) - - test('the timeout is configurable per test URL', () => { - expect(classifyDelay(3000, 2000)).toBe('timeout') - expect(classifyDelay(3000, 5000)).toBe('measured') - }) -}) - -describe('compareByDelay', () => { - const sorted = (delays: number[]) => - [...delays].sort((a, b) => compareByDelay(a, b)) - - test('faster measurements come first', () => { - expect(sorted([300, 100, 200])).toEqual([100, 200, 300]) - }) - - test('a timeout never sorts ahead of a measurement, however it is encoded', () => { - expect(sorted([0, 150])).toEqual([150, 0]) - expect(sorted([DEFAULT_DELAY_TIMEOUT, 150])).toEqual([ - 150, - DEFAULT_DELAY_TIMEOUT, +describe('delay semantics', () => { + test('classifies measurements and core sentinel values', () => { + expect([ + classifyDelay(120), + classifyDelay(-2), + classifyDelay(-1), + classifyDelay(0), + classifyDelay(DEFAULT_DELAY_TIMEOUT), + classifyDelay(1e5 + 1), + classifyDelay(Number.NaN), + ]).toEqual([ + 'measured', + 'testing', + 'untested', + 'timeout', + 'timeout', + 'error', + 'untested', ]) + expect(classifyDelay(3000, 5000)).toBe('measured') + expect(classifyDelay(3000, 2000)).toBe('timeout') }) - test('untested nodes sink below everything measurable', () => { - expect(sorted([-1, 0, 1e5 + 1, 150])).toEqual([150, 0, 1e5 + 1, -1]) + test('sorts measurements first and sentinel states by meaning', () => { + const delays = [-1, 0, -2, 1e5 + 1, 300, 100] + + expect(delays.sort(compareByDelay)).toEqual([100, 300, 0, 1e5 + 1, -2, -1]) }) - test('a node being tested outranks one never tested', () => { - expect(sorted([-1, -2])).toEqual([-2, -1]) - }) - - test('non-numbers sink rather than scrambling the order around them', () => { - // This is the case the two previous copies disagreed on: one sorted a non-number - // among the timeouts, the other after the sentinels. - expect(compareByDelay(Number.NaN, 150)).toBeGreaterThan(0) - expect(compareByDelay(Number.NaN, -1)).toBe(0) - }) - - test('the order is total, so sorting is stable whichever way a pair is compared', () => { - const direction = (value: number) => (value > 0 ? 1 : value < 0 ? -1 : 0) - const samples = [150, 0, -1, -2, 1e5 + 1, 20] + test('keeps the comparator symmetric', () => { + const direction = (value: number) => Math.sign(value) + const samples = [150, 0, -1, -2, 1e5 + 1] for (const a of samples) { for (const b of samples) { - // Summing avoids comparing against -0, which Object.is treats as distinct from 0. expect( direction(compareByDelay(a, b)) + direction(compareByDelay(b, a)), ).toBe(0) diff --git a/vitest.config.mts b/vitest.config.mts index 0fe041b82..81f3adac7 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -2,8 +2,6 @@ import path from 'node:path' import { defineConfig } from 'vitest/config' -// Separate from vite.config.mts because that one sets `root: 'src'` for the app build, -// while tests live alongside the code they cover *and* under tests/. export default defineConfig({ resolve: { alias: { @@ -15,10 +13,7 @@ export default defineConfig({ OS_PLATFORM: `"${process.platform}"`, }, test: { - include: ['tests/**/*.test.ts', 'src/**/*.test.{ts,tsx}'], - // Node by default because most tests are pure functions; render tests opt in per file - // with `// @vitest-environment jsdom`, so the DOM cost is paid only where it buys - // something. + include: ['tests/**/*.test.ts', 'src/**/*.test.ts'], environment: 'node', restoreMocks: true, },