mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
test: trim redundant test coverage
This commit is contained in:
@@ -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::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
|
use clash_verge_draft::{Draft, DraftTransaction};
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
use tokio::time::sleep;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
struct IVerge {
|
struct Config {
|
||||||
enable_auto_launch: Option<bool>,
|
enabled: bool,
|
||||||
enable_tun_mode: Option<bool>,
|
port: u16,
|
||||||
}
|
|
||||||
|
|
||||||
const STRESS_LEVELS: [usize; 6] = [8, 16, 32, 64, 128, 256];
|
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn block_on_ready<F: Future>(fut: F) -> F::Output {
|
|
||||||
tokio::runtime::Runtime::new().unwrap().block_on(fut)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_draft_basic_flow() {
|
fn edits_can_be_committed_or_discarded() {
|
||||||
let draft = Draft::new(verge(true, false));
|
let draft = Draft::new(Config {
|
||||||
|
enabled: false,
|
||||||
{
|
port: 7897,
|
||||||
let data = draft.data_arc();
|
|
||||||
assert_eq!(data.enable_auto_launch, Some(true));
|
|
||||||
assert_eq!(data.enable_tun_mode, Some(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
draft.edit_draft(|d| {
|
|
||||||
d.enable_auto_launch = Some(false);
|
|
||||||
d.enable_tun_mode = Some(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
{
|
draft.edit_draft(|config| config.enabled = true);
|
||||||
let data = draft.data_arc();
|
assert!(!draft.data_arc().enabled);
|
||||||
assert_eq!(data.enable_auto_launch, Some(true));
|
assert!(draft.latest_arc().enabled);
|
||||||
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();
|
draft.apply();
|
||||||
|
assert!(draft.data_arc().enabled);
|
||||||
|
|
||||||
{
|
draft.edit_draft(|config| config.port = 9090);
|
||||||
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.discard();
|
||||||
|
assert_eq!(draft.latest_arc().port, 7897);
|
||||||
{
|
|
||||||
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]
|
#[tokio::test]
|
||||||
fn test_arc_pointer_behavior_on_edit_and_apply() {
|
async fn async_updates_are_serialized() -> anyhow::Result<()> {
|
||||||
let draft = Draft::new(verge(true, false));
|
let draft = Arc::new(Draft::new(0_u8));
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
|
||||||
let committed = draft.data_arc();
|
for _ in 0..16 {
|
||||||
let latest = draft.latest_arc();
|
let draft = Arc::clone(&draft);
|
||||||
assert!(Arc::ptr_eq(&committed, &latest));
|
updates.push(tokio::spawn(async move {
|
||||||
|
draft.with_data_modify(|value| async move { Ok((value + 1, ())) }).await
|
||||||
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"))
|
|
||||||
}));
|
}));
|
||||||
assert_eq!(res.unwrap(), "done");
|
}
|
||||||
|
for update in updates {
|
||||||
let committed = draft.data_arc();
|
update.await??;
|
||||||
assert_eq!(committed.enable_auto_launch, Some(true));
|
|
||||||
assert_eq!(committed.enable_tun_mode, Some(false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
assert_eq!(**draft.data_arc(), 16);
|
||||||
fn test_with_data_modify_keeps_boxed_data_shape() {
|
Ok(())
|
||||||
let draft = Draft::new(Box::new(verge(false, false)));
|
|
||||||
|
|
||||||
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]
|
#[tokio::test]
|
||||||
fn test_with_data_modify_error_releases_permit() {
|
async fn async_update_rejects_a_changed_committed_value() -> anyhow::Result<()> {
|
||||||
let draft = Draft::new(IVerge::default());
|
let draft = Arc::new(Draft::new(Config::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 started = Arc::new(Notify::new());
|
||||||
let finish = Arc::new(Notify::new());
|
let finish = Arc::new(Notify::new());
|
||||||
|
let update = {
|
||||||
rt.block_on(async {
|
|
||||||
let task = {
|
|
||||||
let draft = Arc::clone(&draft);
|
let draft = Arc::clone(&draft);
|
||||||
let started = Arc::clone(&started);
|
let started = Arc::clone(&started);
|
||||||
let finish = Arc::clone(&finish);
|
let finish = Arc::clone(&finish);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
draft
|
draft
|
||||||
.with_data_modify(|mut v| async move {
|
.with_data_modify(|mut config| async move {
|
||||||
started.notify_one();
|
started.notify_one();
|
||||||
finish.notified().await;
|
finish.notified().await;
|
||||||
v.enable_auto_launch = Some(true);
|
config.enabled = true;
|
||||||
Ok((v, ()))
|
Ok((config, ()))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
started.notified().await;
|
started.notified().await;
|
||||||
draft.edit_draft(|d| d.enable_tun_mode = Some(true));
|
draft.edit_draft(|config| config.port = 7897);
|
||||||
draft.apply();
|
draft.apply();
|
||||||
finish.notify_one();
|
finish.notify_one();
|
||||||
|
|
||||||
let err = task.await.expect("task panicked").unwrap_err();
|
assert!(update.await?.is_err());
|
||||||
assert_eq!(
|
assert!(!draft.data_arc().enabled);
|
||||||
format!("{err}"),
|
assert_eq!(draft.data_arc().port, 7897);
|
||||||
"Optimistic lock failed: Committed data has changed during async operation"
|
Ok(())
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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>(())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_with_data_modify_does_not_touch_existing_draft() {
|
fn transaction_commits_every_layer() -> anyhow::Result<()> {
|
||||||
let draft = Draft::new(verge(false, false));
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod transactions {
|
|
||||||
use clash_verge_draft::{Draft, DraftTransaction};
|
|
||||||
|
|
||||||
fn begin<'a>(layers: Vec<&'a dyn clash_verge_draft::DraftLayer>) -> DraftTransaction<'a> {
|
|
||||||
DraftTransaction::begin(layers).expect("no other transaction should hold these layers")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn committing_applies_every_layer() {
|
|
||||||
let first = Draft::new(1_u8);
|
let first = Draft::new(1_u8);
|
||||||
let second = Draft::new("a".to_owned());
|
let second = Draft::new(String::from("a"));
|
||||||
|
let transaction = DraftTransaction::begin(vec![&first, &second])?;
|
||||||
|
|
||||||
let transaction = begin(vec![&first, &second]);
|
|
||||||
first.edit_draft(|value| *value = 2);
|
first.edit_draft(|value| *value = 2);
|
||||||
second.edit_draft(|value| *value = "b".to_owned());
|
second.edit_draft(|value| *value = String::from("b"));
|
||||||
transaction.commit();
|
transaction.commit();
|
||||||
|
|
||||||
assert_eq!(**first.data_arc(), 2);
|
assert_eq!(**first.data_arc(), 2);
|
||||||
assert_eq!(**second.data_arc(), "b");
|
assert_eq!(**second.data_arc(), "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<u8>, 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(())
|
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]
|
#[test]
|
||||||
fn a_partly_edited_transaction_still_rolls_back_whole() {
|
fn dropped_transaction_rolls_every_layer_back() -> anyhow::Result<()> {
|
||||||
// The layer nobody edited must not be left holding a stale draft either.
|
let first = Draft::new(1_u8);
|
||||||
let edited = Draft::new(1_u8);
|
let second = Draft::new(String::from("a"));
|
||||||
let untouched = Draft::new(5_u8);
|
|
||||||
|
|
||||||
drop(begin(vec![&edited, &untouched]));
|
|
||||||
edited.edit_draft(|value| *value = 2);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
let _transaction = begin(vec![&edited, &untouched]);
|
let _transaction = DraftTransaction::begin(vec![&first, &second])?;
|
||||||
|
first.edit_draft(|value| *value = 2);
|
||||||
|
second.edit_draft(|value| *value = String::from("b"));
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(**edited.latest_arc(), 1, "the earlier stray draft is cleared too");
|
assert_eq!(**first.latest_arc(), 1);
|
||||||
assert_eq!(**untouched.latest_arc(), 5);
|
assert_eq!(**second.latest_arc(), "a");
|
||||||
}
|
Ok(())
|
||||||
}
|
|
||||||
|
|
||||||
mod transaction_exclusivity {
|
|
||||||
use clash_verge_draft::{Draft, DraftTransaction};
|
|
||||||
|
|
||||||
#[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");
|
|
||||||
|
|
||||||
let refused = DraftTransaction::begin(vec![&draft]);
|
|
||||||
|
|
||||||
assert!(refused.is_err(), "a layer may only be staged by one transaction");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_refused_transaction_leaves_the_layers_it_did_claim_free() {
|
fn transaction_claims_are_exclusive_and_release_partial_claims() -> anyhow::Result<()> {
|
||||||
// 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 first = Draft::new(1_u8);
|
||||||
let second = Draft::new(2_u8);
|
let second = Draft::new(2_u8);
|
||||||
let _contended = DraftTransaction::begin(vec![&second]).expect("the first claim should succeed");
|
let held = DraftTransaction::begin(vec![&second])?;
|
||||||
|
|
||||||
assert!(DraftTransaction::begin(vec![&first, &second]).is_err());
|
assert!(DraftTransaction::begin(vec![&first, &second]).is_err());
|
||||||
|
let available = DraftTransaction::begin(vec![&first])?;
|
||||||
|
|
||||||
drop(DraftTransaction::begin(vec![&first]).expect("the layer that was rolled off must be free"));
|
drop(available);
|
||||||
}
|
drop(held);
|
||||||
|
assert!(DraftTransaction::begin(vec![&second]).is_ok());
|
||||||
#[test]
|
Ok(())
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -61,12 +61,13 @@ impl<C: Clock> Limiter<C> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod extra_tests {
|
mod tests {
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
struct MockClock(AtomicU64);
|
struct MockClock(AtomicU64);
|
||||||
|
|
||||||
impl Clock for MockClock {
|
impl Clock for MockClock {
|
||||||
fn now_ms(&self) -> u64 {
|
fn now_ms(&self) -> u64 {
|
||||||
self.0.load(Ordering::SeqCst)
|
self.0.load(Ordering::SeqCst)
|
||||||
@@ -74,92 +75,35 @@ mod extra_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_zero_period_always_passes() {
|
fn enforces_the_period_boundary() {
|
||||||
let mock = MockClock(AtomicU64::new(100));
|
let clock = MockClock(AtomicU64::new(1_000));
|
||||||
let limiter = Limiter::new(Duration::from_millis(0), &mock);
|
let limiter = Limiter::new(Duration::from_millis(100), &clock);
|
||||||
|
|
||||||
assert!(limiter.check());
|
assert!(limiter.check());
|
||||||
assert!(limiter.check());
|
clock.0.store(1_099, Ordering::SeqCst);
|
||||||
}
|
|
||||||
|
|
||||||
#[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);
|
|
||||||
assert!(!limiter.check());
|
assert!(!limiter.check());
|
||||||
|
clock.0.store(1_100, Ordering::SeqCst);
|
||||||
mock.0.store(1100, Ordering::SeqCst);
|
|
||||||
assert!(limiter.check(), "Should pass exactly at period boundary");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)));
|
|
||||||
|
|
||||||
assert!(limiter.check());
|
|
||||||
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::unwrap_used)]
|
|
||||||
let results: Vec<bool> = 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());
|
assert!(limiter.check());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_system_clock_real_path() {
|
fn concurrent_checks_admit_one_caller() {
|
||||||
let clock = SystemClock;
|
let clock = Arc::new(MockClock(AtomicU64::new(1_000)));
|
||||||
let start = clock.now_ms();
|
let limiter = Arc::new(Limiter::new(Duration::from_millis(100), Arc::clone(&clock)));
|
||||||
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());
|
assert!(limiter.check());
|
||||||
|
clock.0.store(1_100, Ordering::SeqCst);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
#[test]
|
assert_eq!(admitted, 1);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,37 +37,3 @@ fn alpha2_to_emoji(alpha2: &str) -> String {
|
|||||||
.and_then(|x| char::from_u32(c2).map(|y| format!("{x}{y}")))
|
.and_then(|x| char::from_u32(c2).map(|y| format!("{x}{y}")))
|
||||||
.unwrap_or_default()
|
.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"), "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:dev-control": "node --test scripts/dev-control.test.mjs",
|
|
||||||
"knip:check": "knip",
|
"knip:check": "knip",
|
||||||
"knip:report": "knip --no-exit-code"
|
"knip:report": "knip --no-exit-code"
|
||||||
},
|
},
|
||||||
@@ -91,7 +90,6 @@
|
|||||||
"@eslint-react/eslint-plugin": "^5.8.5",
|
"@eslint-react/eslint-plugin": "^5.8.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tauri-apps/cli": "2.11.4",
|
"@tauri-apps/cli": "2.11.4",
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^24.12.4",
|
"@types/node": "^24.12.4",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
@@ -118,7 +116,6 @@
|
|||||||
"https-proxy-agent": "^9.0.0",
|
"https-proxy-agent": "^9.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jiti": "^2.7.0",
|
"jiti": "^2.7.0",
|
||||||
"jsdom": "^30.0.0",
|
|
||||||
"knip": "6.32.1",
|
"knip": "6.32.1",
|
||||||
"lint-staged": "^17.0.5",
|
"lint-staged": "^17.0.5",
|
||||||
"sass": "^1.100.0",
|
"sass": "^1.100.0",
|
||||||
|
|||||||
Generated
-6
@@ -150,9 +150,6 @@ importers:
|
|||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: 2.11.4
|
specifier: 2.11.4
|
||||||
version: 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':
|
'@types/lodash-es':
|
||||||
specifier: ^4.17.12
|
specifier: ^4.17.12
|
||||||
version: 4.17.12
|
version: 4.17.12
|
||||||
@@ -231,9 +228,6 @@ importers:
|
|||||||
jiti:
|
jiti:
|
||||||
specifier: ^2.7.0
|
specifier: ^2.7.0
|
||||||
version: 2.7.0
|
version: 2.7.0
|
||||||
jsdom:
|
|
||||||
specifier: ^30.0.0
|
|
||||||
version: 30.0.1
|
|
||||||
knip:
|
knip:
|
||||||
specifier: 6.32.1
|
specifier: 6.32.1
|
||||||
version: 6.32.1
|
version: 6.32.1
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ allowBuilds:
|
|||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- dayjs@1.11.21
|
- dayjs@1.11.21
|
||||||
- jsdom@30.0.0
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+17
-125
@@ -166,24 +166,26 @@ impl<T, E: std::fmt::Display> WithErrorCode<T> for Result<T, E> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 anyhow::Context as _;
|
||||||
|
|
||||||
|
use super::{CommandFailure, WithErrorCode as _, proxy_aware_coded_error};
|
||||||
|
use crate::core::proxy_control::SysproxyFailure;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn coded_error_preserves_stable_code_and_diagnostic_detail() {
|
fn command_failure_matches_the_frontend_wire_contract() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
coded_error("CORE_RESTART_FAILED", "connection refused"),
|
serde_json::to_value(CommandFailure::coded("CORE_START_FAILED", "connection refused"))
|
||||||
CommandFailure {
|
.unwrap_or(serde_json::Value::Null),
|
||||||
code: Some("CORE_RESTART_FAILED".to_owned()),
|
serde_json::json!({ "code": "CORE_START_FAILED", "detail": "connection refused" })
|
||||||
detail: "connection refused".to_owned(),
|
);
|
||||||
operation: None,
|
assert_eq!(
|
||||||
}
|
serde_json::to_value(CommandFailure::plain("disk full")).unwrap_or(serde_json::Value::Null),
|
||||||
|
serde_json::json!({ "detail": "disk full" })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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"))
|
let source: anyhow::Result<()> = Err(anyhow::anyhow!("connection refused"))
|
||||||
.context("failed to reach the mihomo core")
|
.context("failed to reach the mihomo core")
|
||||||
.context("failed to restart the core");
|
.context("failed to restart the core");
|
||||||
@@ -193,37 +195,15 @@ mod tests {
|
|||||||
.err()
|
.err()
|
||||||
.unwrap_or_else(|| CommandFailure::plain("the call should have failed"));
|
.unwrap_or_else(|| CommandFailure::plain("the call should have failed"));
|
||||||
|
|
||||||
|
assert_eq!(failure.code.as_deref(), Some("CORE_RESTART_FAILED"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
failure,
|
failure.detail,
|
||||||
CommandFailure {
|
"failed to restart the core: failed to reach the mihomo core: connection refused"
|
||||||
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,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn with_error_code_leaves_plain_display_errors_untouched() {
|
fn system_proxy_classification_outranks_the_operation_code() {
|
||||||
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() {
|
|
||||||
let failure = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges)
|
let failure = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges)
|
||||||
.context(SysproxyFailure::PrivilegeRequired)
|
.context(SysproxyFailure::PrivilegeRequired)
|
||||||
.context("failed to restart the core");
|
.context("failed to restart the core");
|
||||||
@@ -231,94 +211,6 @@ mod tests {
|
|||||||
let reported = proxy_aware_coded_error(&failure, "CORE_RESTART_FAILED");
|
let reported = proxy_aware_coded_error(&failure, "CORE_RESTART_FAILED");
|
||||||
|
|
||||||
assert_eq!(reported.code.as_deref(), Some("SYSPROXY_PRIVILEGE_REQUIRED"));
|
assert_eq!(reported.code.as_deref(), Some("SYSPROXY_PRIVILEGE_REQUIRED"));
|
||||||
assert!(reported.detail.contains("failed to restart the core"), "{reported:?}");
|
assert!(reported.detail.contains("failed to restart the core"));
|
||||||
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" })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,31 +32,3 @@ const fn system_proxy_operation(payload: &IVerge) -> Option<FailedOperation> {
|
|||||||
None => None,
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -385,37 +385,10 @@ pub enum ConfigType {
|
|||||||
Run,
|
Run,
|
||||||
Check,
|
Check,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::<Draft<IRuntime>>());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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::<Draft<Box<IRuntime>>>());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn failed_profile_index_survives_startup_without_cleanup() -> Result<()> {
|
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?;
|
let profile_was_preserved = tokio::fs::try_exists(&active_profile).await?;
|
||||||
tokio::fs::remove_dir_all(&profiles_dir).await?;
|
tokio::fs::remove_dir_all(&profiles_dir).await?;
|
||||||
|
|
||||||
assert!(
|
assert!(profile_was_preserved);
|
||||||
profile_was_preserved,
|
assert!(profiles.data_arc().get_items().is_none());
|
||||||
"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"
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,40 +73,23 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_selected_port_wins_over_the_merge_config() {
|
fn desired_port_uses_session_then_selection_then_merge_priority() {
|
||||||
assert_eq!(resolve_desired(None, Some(9000), 7897), 9000);
|
assert_eq!(resolve_desired(Some(7900), Some(9000), 8080), 7900);
|
||||||
}
|
assert_eq!(resolve_desired(None, Some(9000), 8080), 9000);
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_merge_config_answers_when_nothing_is_selected() {
|
|
||||||
assert_eq!(resolve_desired(None, None, 8080), 8080);
|
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]
|
#[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);
|
assert_eq!(resolve_effective(|| async { Ok(7898) }, 7897).await, 7898);
|
||||||
}
|
assert_eq!(resolve_effective(|| async { Ok(0) }, 7897).await, 7897);
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn an_unreachable_core_leaves_the_configured_port_standing() {
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_effective(|| async { anyhow::bail!("core is not running") }, 7897).await,
|
resolve_effective(|| async { anyhow::bail!("core is not running") }, 7897).await,
|
||||||
7897
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -565,19 +565,3 @@ fn fix_dirty_url(input: &str) -> Result<Url> {
|
|||||||
|
|
||||||
Ok(url)
|
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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::<UpdateCacheMeta>("not valid json");
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_cache_meta_missing_required_field() {
|
|
||||||
let result = serde_json::from_str::<UpdateCacheMeta>(r#"{"version":"2.5.0"}"#);
|
|
||||||
assert!(result.is_err()); // missing downloaded_at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -173,103 +173,3 @@ pub async fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult<Stri
|
|||||||
Err("file not found".into())
|
Err("file not found".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[allow(clippy::unwrap_used)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_icon_segment_accepts_single_name() {
|
|
||||||
assert!(normalize_icon_segment("group-icon.png").is_ok());
|
|
||||||
assert!(normalize_icon_segment("alpha_1.webp").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_icon_segment_rejects_traversal_and_separators() {
|
|
||||||
for name in ["../x", "..\\x", "a/b", "a\\b", "..", "a..b"] {
|
|
||||||
assert!(normalize_icon_segment(name).is_err(), "name should be rejected: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_icon_segment_rejects_empty() {
|
|
||||||
assert!(normalize_icon_segment("").is_err());
|
|
||||||
assert!(normalize_icon_segment(" ").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
#[test]
|
|
||||||
fn normalize_icon_segment_rejects_windows_absolute_names() {
|
|
||||||
for name in [r"C:\temp\icon.png", r"\\server\share\icon.png"] {
|
|
||||||
assert!(normalize_icon_segment(name).is_err(), "name should be rejected: {name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
#[test]
|
|
||||||
fn normalize_icon_segment_rejects_unix_absolute_names() {
|
|
||||||
assert!(normalize_icon_segment("/tmp/icon.png").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ensure_icon_cache_target_accepts_direct_child_only() {
|
|
||||||
let base = PathBuf::from("icons").join("cache");
|
|
||||||
let valid = ensure_icon_cache_target(&base, "ok.png");
|
|
||||||
assert_eq!(valid.unwrap(), base.join("ok.png"));
|
|
||||||
|
|
||||||
let nested = base.join("nested").join("ok.png");
|
|
||||||
assert!(ensure_icon_cache_target(&base, nested.to_string_lossy().as_ref()).is_err());
|
|
||||||
assert!(ensure_icon_cache_target(&base, "../ok.png").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn looks_like_svg_accepts_plain_svg() {
|
|
||||||
assert!(looks_like_svg(br#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn looks_like_svg_accepts_xml_prefixed_svg() {
|
|
||||||
assert!(looks_like_svg(
|
|
||||||
br#"<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"></svg>"#
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn looks_like_svg_accepts_doctype_svg() {
|
|
||||||
assert!(looks_like_svg(
|
|
||||||
br#"<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"><svg xmlns="http://www.w3.org/2000/svg"></svg>"#
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn looks_like_svg_accepts_bom_and_leading_whitespace() {
|
|
||||||
assert!(looks_like_svg(
|
|
||||||
b"\xEF\xBB\xBF \n\t<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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"<!DOCTYPE html><html></html>"));
|
|
||||||
assert!(looks_like_html(br"<html><body>oops</body></html>"));
|
|
||||||
assert!(looks_like_html(br"<head><title>oops</title></head>"));
|
|
||||||
assert!(looks_like_html(
|
|
||||||
b"\xEF\xBB\xBF \n\t<!DOCTYPE HTML><html><body>oops</body></html>"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_supported_icon_content_rejects_html_and_accepts_svg() {
|
|
||||||
assert!(!is_supported_icon_content(br"<!DOCTYPE html><html></html>"));
|
|
||||||
assert!(is_supported_icon_content(
|
|
||||||
br#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -137,15 +137,3 @@ pub async fn copy_clash_env() {
|
|||||||
logging!(error, Type::ProxyMode, "Failed to write to clipboard");
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -184,26 +184,3 @@ pub fn reload_main_window_if_needed() {
|
|||||||
logging!(warn, Type::Window, "重载页面失败: {e}");
|
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,31 +30,3 @@ pub fn format_bytes_per_second(bytes_per_sec: u64) -> String {
|
|||||||
format!("{:.0}{}", value.round(), SPEED_UNITS[unit_index])
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -63,23 +63,3 @@ fn append_error(path: &Path, detail: &str) -> Result<()> {
|
|||||||
file.flush().context("failed to flush startup log")?;
|
file.flush().context("failed to flush startup log")?;
|
||||||
Ok(())
|
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(<NoticeManager />)
|
|
||||||
|
|
||||||
expect(noticeText()).toContain('connection refused')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shortens an unbounded detail', () => {
|
|
||||||
showNotice.error({
|
|
||||||
code: 'SERVICE_INSTALL_FAILED',
|
|
||||||
detail: `${'x'.repeat(4000)} tail`,
|
|
||||||
})
|
|
||||||
render(<NoticeManager />)
|
|
||||||
|
|
||||||
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(<NoticeManager />)
|
|
||||||
|
|
||||||
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(<NoticeManager />)
|
|
||||||
|
|
||||||
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(<NoticeManager />)
|
|
||||||
|
|
||||||
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(<NoticeManager />)
|
|
||||||
|
|
||||||
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(<NoticeManager />)
|
|
||||||
|
|
||||||
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(<NoticeManager />)
|
|
||||||
|
|
||||||
fireEvent.contextMenu(screen.getByRole('alert'))
|
|
||||||
|
|
||||||
await waitFor(() => expect(writeText).toHaveBeenCalledOnce())
|
|
||||||
expect(writeText.mock.calls[0]?.[0]).toContain(detail)
|
|
||||||
})
|
|
||||||
@@ -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<string, () => 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(<SysproxyPrivilegeDialog />)
|
|
||||||
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<void>((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(<SysproxyPrivilegeDialog />)
|
|
||||||
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(<SysproxyPrivilegeDialog />)
|
|
||||||
onSubscribed?.()
|
|
||||||
|
|
||||||
act(() => requestService({ reason: 'tunNeedsService' }))
|
|
||||||
await screen.findByRole('dialog')
|
|
||||||
clickPrimary('settings.sections.proxyControl.actions.installService')
|
|
||||||
|
|
||||||
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
|
|
||||||
expect(patchVergeConfig).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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(
|
|
||||||
<ProxyControlSwitches label="settings.sections.system.toggles.tunMode" />,
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
<ProxyControlSwitches
|
|
||||||
label="settings.sections.system.toggles.tunMode"
|
|
||||||
onError={onError}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
flip()
|
|
||||||
|
|
||||||
await waitFor(() => expect(getServiceRequest()).not.toBeNull())
|
|
||||||
await waitFor(() => expect(theSwitch().checked).toBe(false))
|
|
||||||
expect(onError).not.toHaveBeenCalled()
|
|
||||||
expect(getSnapshotNotices()).toEqual([])
|
|
||||||
})
|
|
||||||
@@ -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<void>((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())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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<unknown> = 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)
|
|
||||||
})
|
|
||||||
@@ -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())
|
|
||||||
})
|
|
||||||
@@ -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<string, () => 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))
|
|
||||||
})
|
|
||||||
@@ -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(
|
|
||||||
<AppDataProvider>
|
|
||||||
<span>child rendered</span>
|
|
||||||
</AppDataProvider>,
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
<AppDataProvider>
|
|
||||||
<span>child rendered</span>
|
|
||||||
</AppDataProvider>,
|
|
||||||
),
|
|
||||||
).not.toThrow()
|
|
||||||
|
|
||||||
const reported = errors.flat().join(' ')
|
|
||||||
expect(reported).not.toContain('must be used within AppDataProvider')
|
|
||||||
|
|
||||||
consoleError.mockRestore()
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
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', () => ({
|
vi.mock('tauri-plugin-mihomo-api', () => ({
|
||||||
delayProxyByName: vi.fn(async () => ({ delay: 120 })),
|
delayProxyByName: vi.fn(async () => ({ delay: 120 })),
|
||||||
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
|
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
|
||||||
@@ -22,7 +21,7 @@ const node = (name: string) =>
|
|||||||
},
|
},
|
||||||
}) as unknown as ResolvedProxyMember
|
}) as unknown as ResolvedProxyMember
|
||||||
|
|
||||||
const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0))
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
let settles = 0
|
let settles = 0
|
||||||
let unsubscribe: () => void
|
let unsubscribe: () => void
|
||||||
@@ -34,90 +33,26 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => unsubscribe())
|
||||||
unsubscribe()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('group settle notifications', () => {
|
describe('group delay completion', () => {
|
||||||
test('a single test settles the group once', async () => {
|
test('notifies once after a batch settles', 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.
|
|
||||||
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
|
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
|
||||||
|
|
||||||
const batch = delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
|
await delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
|
||||||
await delayManager.checkDelay(node('single') as never, 'g', 5000)
|
await flush()
|
||||||
await nextFrame()
|
|
||||||
expect(settles).toBe(0)
|
|
||||||
|
|
||||||
await batch
|
|
||||||
await nextFrame()
|
|
||||||
expect(settles).toBe(1)
|
expect(settles).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unsubscribing one listener leaves the others subscribed', async () => {
|
test('notifies only listeners for the completed group', 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 () => {
|
|
||||||
let other = 0
|
let other = 0
|
||||||
const stop = delayManager.addGroupListener('other', () => {
|
const stop = delayManager.addGroupListener('other', () => {
|
||||||
other += 1
|
other += 1
|
||||||
})
|
})
|
||||||
|
|
||||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||||
await nextFrame()
|
await flush()
|
||||||
|
|
||||||
expect(settles).toBe(1)
|
expect(settles).toBe(1)
|
||||||
expect(other).toBe(0)
|
expect(other).toBe(0)
|
||||||
|
|||||||
+12
-60
@@ -6,22 +6,6 @@ const listen = vi.hoisted(() => vi.fn())
|
|||||||
|
|
||||||
vi.mock('@tauri-apps/api/event', () => ({ listen }))
|
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))
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -29,8 +13,14 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('subscribeVergeEvents', () => {
|
describe('subscribeVergeEvents', () => {
|
||||||
it('reports subscribed only once every listener is live', async () => {
|
it('reports readiness only after every listener is registered', async () => {
|
||||||
const { registrations, settleAll } = deferredListen()
|
const registrations: Array<() => void> = []
|
||||||
|
listen.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
registrations.push(() => resolve(() => {}))
|
||||||
|
}),
|
||||||
|
)
|
||||||
const onSubscribed = vi.fn()
|
const onSubscribed = vi.fn()
|
||||||
|
|
||||||
subscribeVergeEvents(
|
subscribeVergeEvents(
|
||||||
@@ -41,48 +31,16 @@ describe('subscribeVergeEvents', () => {
|
|||||||
onSubscribed,
|
onSubscribed,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(registrations).toHaveLength(2)
|
|
||||||
registrations[0]?.()
|
registrations[0]?.()
|
||||||
await flush()
|
await flush()
|
||||||
expect(onSubscribed).not.toHaveBeenCalled()
|
expect(onSubscribed).not.toHaveBeenCalled()
|
||||||
|
|
||||||
settleAll()
|
registrations[1]?.()
|
||||||
await flush()
|
await flush()
|
||||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
expect(onSubscribed).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not report subscribed after teardown', async () => {
|
it('delivers a payload to its matching handler', () => {
|
||||||
// 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 () => {
|
|
||||||
const handlers = new Map<string, (payload: unknown) => void>()
|
const handlers = new Map<string, (payload: unknown) => void>()
|
||||||
listen.mockImplementation(
|
listen.mockImplementation(
|
||||||
(name: string, handler: (event: unknown) => void) => {
|
(name: string, handler: (event: unknown) => void) => {
|
||||||
@@ -91,16 +49,10 @@ describe('subscribeVergeEvents', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const onRunState = vi.fn()
|
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' })
|
handlers.get('verge://run-state-changed')?.({ mode: 'Sidecar' })
|
||||||
|
|
||||||
expect(onRunState).toHaveBeenCalledWith({ mode: 'Sidecar' })
|
expect(onRunState).toHaveBeenCalledWith({ mode: 'Sidecar' })
|
||||||
expect(onNotice).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
+27
-66
@@ -2,80 +2,41 @@ import { describe, expect, test } from 'vitest'
|
|||||||
|
|
||||||
import { classifyDelay, compareByDelay, DEFAULT_DELAY_TIMEOUT } from './delay'
|
import { classifyDelay, compareByDelay, DEFAULT_DELAY_TIMEOUT } from './delay'
|
||||||
|
|
||||||
describe('classifyDelay', () => {
|
describe('delay semantics', () => {
|
||||||
test('an ordinary latency is a measurement', () => {
|
test('classifies measurements and core sentinel values', () => {
|
||||||
expect(classifyDelay(120)).toBe('measured')
|
expect([
|
||||||
expect(classifyDelay(DEFAULT_DELAY_TIMEOUT - 1)).toBe('measured')
|
classifyDelay(120),
|
||||||
})
|
classifyDelay(-2),
|
||||||
|
classifyDelay(-1),
|
||||||
test('the core reports an in-flight test as -2', () => {
|
classifyDelay(0),
|
||||||
expect(classifyDelay(-2)).toBe('testing')
|
classifyDelay(DEFAULT_DELAY_TIMEOUT),
|
||||||
})
|
classifyDelay(1e5 + 1),
|
||||||
|
classifyDelay(Number.NaN),
|
||||||
test('any other negative means we have never measured it', () => {
|
]).toEqual([
|
||||||
expect(classifyDelay(-1)).toBe('untested')
|
'measured',
|
||||||
expect(classifyDelay(-99)).toBe('untested')
|
'testing',
|
||||||
})
|
'untested',
|
||||||
|
'timeout',
|
||||||
test('zero and anything past the timeout are timeouts, not fast results', () => {
|
'timeout',
|
||||||
// Zero is the trap: sorted as a number it would come first.
|
'error',
|
||||||
expect(classifyDelay(0)).toBe('timeout')
|
'untested',
|
||||||
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,
|
|
||||||
])
|
])
|
||||||
|
expect(classifyDelay(3000, 5000)).toBe('measured')
|
||||||
|
expect(classifyDelay(3000, 2000)).toBe('timeout')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('untested nodes sink below everything measurable', () => {
|
test('sorts measurements first and sentinel states by meaning', () => {
|
||||||
expect(sorted([-1, 0, 1e5 + 1, 150])).toEqual([150, 0, 1e5 + 1, -1])
|
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', () => {
|
test('keeps the comparator symmetric', () => {
|
||||||
expect(sorted([-1, -2])).toEqual([-2, -1])
|
const direction = (value: number) => Math.sign(value)
|
||||||
})
|
const samples = [150, 0, -1, -2, 1e5 + 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]
|
|
||||||
|
|
||||||
for (const a of samples) {
|
for (const a of samples) {
|
||||||
for (const b of samples) {
|
for (const b of samples) {
|
||||||
// Summing avoids comparing against -0, which Object.is treats as distinct from 0.
|
|
||||||
expect(
|
expect(
|
||||||
direction(compareByDelay(a, b)) + direction(compareByDelay(b, a)),
|
direction(compareByDelay(a, b)) + direction(compareByDelay(b, a)),
|
||||||
).toBe(0)
|
).toBe(0)
|
||||||
|
|||||||
+1
-6
@@ -2,8 +2,6 @@ import path from 'node:path'
|
|||||||
|
|
||||||
import { defineConfig } from 'vitest/config'
|
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({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -15,10 +13,7 @@ export default defineConfig({
|
|||||||
OS_PLATFORM: `"${process.platform}"`,
|
OS_PLATFORM: `"${process.platform}"`,
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['tests/**/*.test.ts', 'src/**/*.test.{ts,tsx}'],
|
include: ['tests/**/*.test.ts', 'src/**/*.test.ts'],
|
||||||
// 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.
|
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
restoreMocks: true,
|
restoreMocks: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user