use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use jsdet_core::observation::Value;
#[derive(Clone, Debug)]
pub struct ExtensionState {
pub tabs: Arc<Mutex<Vec<Tab>>>,
pub cookies: Arc<Mutex<Vec<Cookie>>>,
pub storage_local: Arc<Mutex<HashMap<String, String>>>,
pub storage_sync: Arc<Mutex<HashMap<String, String>>>,
pub alarms: Arc<Mutex<Vec<Alarm>>>,
pub pending_messages: Arc<Mutex<Vec<PendingMessage>>>,
pub extension_id: String,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Tab {
pub id: u32,
pub url: String,
pub title: String,
pub active: bool,
pub index: u32,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub secure: bool,
pub http_only: bool,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Alarm {
pub name: String,
pub scheduled_time: f64,
pub period_in_minutes: Option<f64>,
}
#[derive(Clone, Debug)]
pub struct PendingMessage {
pub data: Value,
pub sender_origin: Option<String>,
pub is_external: bool,
pub sender_tab_id: Option<u32>,
}
impl Default for ExtensionState {
fn default() -> Self {
Self {
tabs: Arc::new(Mutex::new(vec![Tab {
id: 1,
url: "https://example.com".into(),
title: "Example".into(),
active: true,
index: 0,
}])),
cookies: Arc::new(Mutex::new(vec![Cookie {
name: "session".into(),
value: "abc123".into(),
domain: ".example.com".into(),
path: "/".into(),
secure: true,
http_only: true,
}])),
storage_local: Arc::new(Mutex::new(HashMap::new())),
storage_sync: Arc::new(Mutex::new(HashMap::new())),
alarms: Arc::new(Mutex::new(Vec::new())),
pending_messages: Arc::new(Mutex::new(Vec::new())),
extension_id: "test-extension-id".into(),
}
}
}
impl ExtensionState {
pub fn default_with_id(id: &str) -> Self {
Self {
extension_id: id.to_string(),
..Self::default()
}
}
pub fn with_tabs(mut self, tabs: Vec<Tab>) -> Self {
self.tabs = Arc::new(Mutex::new(tabs));
self
}
pub fn with_cookies(mut self, cookies: Vec<Cookie>) -> Self {
self.cookies = Arc::new(Mutex::new(cookies));
self
}
pub fn with_storage(mut self, data: HashMap<String, String>) -> Self {
self.storage_local = Arc::new(Mutex::new(data));
self
}
pub fn queue_message(&self, msg: PendingMessage) {
if let Ok(mut guard) = self.pending_messages.lock() {
guard.push(msg);
}
}
pub fn take_messages(&self) -> Vec<PendingMessage> {
let mut guard = self
.pending_messages
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::take(&mut *guard)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_state_has_one_tab() {
let state = ExtensionState::default();
let tabs = state.tabs.lock().unwrap();
assert_eq!(tabs.len(), 1);
assert!(tabs[0].active);
}
#[test]
fn default_state_has_session_cookie() {
let state = ExtensionState::default();
let cookies = state.cookies.lock().unwrap();
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name, "session");
}
#[test]
fn default_state_cookie_properties() {
let state = ExtensionState::default();
let cookies = state.cookies.lock().unwrap();
let cookie = &cookies[0];
assert_eq!(cookie.value, "abc123");
assert_eq!(cookie.domain, ".example.com");
assert_eq!(cookie.path, "/");
assert!(cookie.secure);
assert!(cookie.http_only);
}
#[test]
fn default_state_tab_properties() {
let state = ExtensionState::default();
let tabs = state.tabs.lock().unwrap();
let tab = &tabs[0];
assert_eq!(tab.id, 1);
assert_eq!(tab.url, "https://example.com");
assert_eq!(tab.title, "Example");
assert_eq!(tab.index, 0);
assert!(tab.active);
}
#[test]
fn default_state_storage_is_empty() {
let state = ExtensionState::default();
assert!(state.storage_local.lock().unwrap().is_empty());
assert!(state.storage_sync.lock().unwrap().is_empty());
}
#[test]
fn default_state_alarms_is_empty() {
let state = ExtensionState::default();
assert!(state.alarms.lock().unwrap().is_empty());
}
#[test]
fn default_state_pending_messages_is_empty() {
let state = ExtensionState::default();
assert!(state.pending_messages.lock().unwrap().is_empty());
}
#[test]
fn default_state_extension_id() {
let state = ExtensionState::default();
assert_eq!(state.extension_id, "test-extension-id");
}
#[test]
fn message_queue() {
let state = ExtensionState::default();
state.queue_message(PendingMessage {
data: Value::string("hello"),
sender_origin: Some("https://evil.com".into()),
is_external: true,
sender_tab_id: None,
});
let msgs = state.take_messages();
assert_eq!(msgs.len(), 1);
assert!(msgs[0].is_external);
assert!(state.take_messages().is_empty());
}
#[test]
fn message_queue_multiple_messages() {
let state = ExtensionState::default();
state.queue_message(PendingMessage {
data: Value::string("msg1"),
sender_origin: Some("https://a.com".into()),
is_external: true,
sender_tab_id: None,
});
state.queue_message(PendingMessage {
data: Value::string("msg2"),
sender_origin: Some("https://b.com".into()),
is_external: false,
sender_tab_id: Some(1),
});
state.queue_message(PendingMessage {
data: Value::json(r#"{"action": "test"}"#),
sender_origin: None,
is_external: false,
sender_tab_id: Some(2),
});
let msgs = state.take_messages();
assert_eq!(msgs.len(), 3);
assert!(msgs[0].is_external);
assert!(!msgs[1].is_external);
assert_eq!(msgs[1].sender_tab_id, Some(1));
}
#[test]
fn message_queue_take_drains_queue() {
let state = ExtensionState::default();
for i in 0..5 {
state.queue_message(PendingMessage {
data: Value::string(format!("msg{}", i)),
sender_origin: None,
is_external: false,
sender_tab_id: None,
});
}
let msgs1 = state.take_messages();
assert_eq!(msgs1.len(), 5);
let msgs2 = state.take_messages();
assert!(msgs2.is_empty());
let msgs3 = state.take_messages();
assert!(msgs3.is_empty());
}
#[test]
fn message_queue_order_preserved() {
let state = ExtensionState::default();
state.queue_message(PendingMessage {
data: Value::string("first"),
sender_origin: None,
is_external: false,
sender_tab_id: None,
});
state.queue_message(PendingMessage {
data: Value::string("second"),
sender_origin: None,
is_external: false,
sender_tab_id: None,
});
state.queue_message(PendingMessage {
data: Value::string("third"),
sender_origin: None,
is_external: false,
sender_tab_id: None,
});
let msgs = state.take_messages();
assert_eq!(msgs[0].data, Value::string("first"));
assert_eq!(msgs[1].data, Value::string("second"));
assert_eq!(msgs[2].data, Value::string("third"));
}
#[test]
fn message_queue_external_vs_internal() {
let state = ExtensionState::default();
state.queue_message(PendingMessage {
data: Value::Null,
sender_origin: Some("https://external.com".into()),
is_external: true,
sender_tab_id: None,
});
state.queue_message(PendingMessage {
data: Value::Null,
sender_origin: None,
is_external: false,
sender_tab_id: Some(1),
});
let msgs = state.take_messages();
assert!(msgs[0].is_external);
assert!(!msgs[1].is_external);
assert_eq!(msgs[0].sender_origin, Some("https://external.com".into()));
assert_eq!(msgs[1].sender_tab_id, Some(1));
}
#[test]
fn custom_state() {
let state = ExtensionState::default()
.with_tabs(vec![
Tab {
id: 1,
url: "https://a.com".into(),
title: "A".into(),
active: true,
index: 0,
},
Tab {
id: 2,
url: "https://b.com".into(),
title: "B".into(),
active: false,
index: 1,
},
])
.with_storage(HashMap::from([("key".into(), "value".into())]));
assert_eq!(state.tabs.lock().unwrap().len(), 2);
assert_eq!(
state.storage_local.lock().unwrap().get("key").unwrap(),
"value"
);
}
#[test]
fn with_tabs_empty() {
let state = ExtensionState::default().with_tabs(vec![]);
assert!(state.tabs.lock().unwrap().is_empty());
}
#[test]
fn with_tabs_single() {
let state = ExtensionState::default().with_tabs(vec![Tab {
id: 42,
url: "https://test.com".into(),
title: "Test".into(),
active: true,
index: 0,
}]);
let tabs = state.tabs.lock().unwrap();
assert_eq!(tabs.len(), 1);
assert_eq!(tabs[0].id, 42);
}
#[test]
fn with_tabs_multiple() {
let tabs_vec: Vec<Tab> = (0..10)
.map(|i| Tab {
id: i,
url: format!("https://site{}.com", i),
title: format!("Site {}", i),
active: i == 0,
index: i as u32,
})
.collect();
let state = ExtensionState::default().with_tabs(tabs_vec);
assert_eq!(state.tabs.lock().unwrap().len(), 10);
}
#[test]
fn with_cookies_empty() {
let state = ExtensionState::default().with_cookies(vec![]);
assert!(state.cookies.lock().unwrap().is_empty());
}
#[test]
fn with_cookies_single() {
let state = ExtensionState::default().with_cookies(vec![Cookie {
name: "custom".into(),
value: "value".into(),
domain: ".custom.com".into(),
path: "/path".into(),
secure: false,
http_only: false,
}]);
let cookies = state.cookies.lock().unwrap();
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name, "custom");
}
#[test]
fn with_cookies_multiple() {
let cookies_vec: Vec<Cookie> = (0..10)
.map(|i| Cookie {
name: format!("cookie{}", i),
value: format!("value{}", i),
domain: format!(".site{}.com", i),
path: "/".into(),
secure: i % 2 == 0,
http_only: i % 2 == 1,
})
.collect();
let state = ExtensionState::default().with_cookies(cookies_vec);
assert_eq!(state.cookies.lock().unwrap().len(), 10);
}
#[test]
fn with_storage_empty() {
let state = ExtensionState::default().with_storage(HashMap::new());
assert!(state.storage_local.lock().unwrap().is_empty());
}
#[test]
fn with_storage_single() {
let mut map = HashMap::new();
map.insert("key1".into(), "value1".into());
let state = ExtensionState::default().with_storage(map);
assert_eq!(
state.storage_local.lock().unwrap().get("key1"),
Some(&"value1".into())
);
}
#[test]
fn with_storage_multiple() {
let map: HashMap<String, String> = (0..10)
.map(|i| (format!("key{}", i), format!("value{}", i)))
.collect();
let state = ExtensionState::default().with_storage(map);
let storage = state.storage_local.lock().unwrap();
assert_eq!(storage.len(), 10);
assert_eq!(storage.get("key5"), Some(&"value5".into()));
}
#[test]
fn with_storage_overwrites_default() {
let mut map = HashMap::new();
map.insert("custom".into(), "data".into());
let state = ExtensionState::default().with_storage(map);
let storage = state.storage_local.lock().unwrap();
assert!(storage.get("custom").is_some());
assert_eq!(storage.len(), 1); }
#[test]
fn chained_builders() {
let state = ExtensionState::default()
.with_tabs(vec![Tab {
id: 1,
url: "https://a.com".into(),
title: "A".into(),
active: true,
index: 0,
}])
.with_cookies(vec![Cookie {
name: "c".into(),
value: "v".into(),
domain: "d".into(),
path: "/".into(),
secure: true,
http_only: true,
}])
.with_storage(HashMap::from([("k".into(), "v".into())]));
assert_eq!(state.tabs.lock().unwrap().len(), 1);
assert_eq!(state.cookies.lock().unwrap().len(), 1);
assert_eq!(state.storage_local.lock().unwrap().len(), 1);
}
#[test]
fn empty_state_tabs() {
let state = ExtensionState::default().with_tabs(vec![]);
assert!(state.tabs.lock().unwrap().is_empty());
assert_eq!(state.tabs.lock().unwrap().len(), 0);
}
#[test]
fn empty_state_cookies() {
let state = ExtensionState::default().with_cookies(vec![]);
assert!(state.cookies.lock().unwrap().is_empty());
}
#[test]
fn empty_state_storage() {
let state = ExtensionState::default().with_storage(HashMap::new());
assert!(state.storage_local.lock().unwrap().is_empty());
}
#[test]
fn empty_state_all() {
let state = ExtensionState::default()
.with_tabs(vec![])
.with_cookies(vec![])
.with_storage(HashMap::new());
assert!(state.tabs.lock().unwrap().is_empty());
assert!(state.cookies.lock().unwrap().is_empty());
assert!(state.storage_local.lock().unwrap().is_empty());
assert!(state.storage_sync.lock().unwrap().is_empty());
assert!(state.alarms.lock().unwrap().is_empty());
assert!(state.pending_messages.lock().unwrap().is_empty());
}
#[test]
fn large_state_many_tabs() {
let tabs: Vec<Tab> = (0..100)
.map(|i| Tab {
id: i,
url: format!("https://site{}.com/page", i),
title: format!("Tab {} Title", i),
active: i == 0,
index: i as u32,
})
.collect();
let state = ExtensionState::default().with_tabs(tabs);
assert_eq!(state.tabs.lock().unwrap().len(), 100);
}
#[test]
fn large_state_many_cookies() {
let cookies: Vec<Cookie> = (0..1000)
.map(|i| Cookie {
name: format!("cookie_{}", i),
value: format!("value_{}_{}", i, "x".repeat(50)),
domain: format!(".domain{}.com", i % 100),
path: "/".into(),
secure: i % 3 == 0,
http_only: i % 5 == 0,
})
.collect();
let state = ExtensionState::default().with_cookies(cookies);
assert_eq!(state.cookies.lock().unwrap().len(), 1000);
}
#[test]
fn large_state_large_storage() {
let storage: HashMap<String, String> = (0..1000)
.map(|i| (format!("key_{}", i), format!("value_{}", i)))
.collect();
let state = ExtensionState::default().with_storage(storage);
assert_eq!(state.storage_local.lock().unwrap().len(), 1000);
}
#[test]
fn concurrent_access_tabs() {
let state = Arc::new(ExtensionState::default());
let state2 = state.clone();
state.tabs.lock().unwrap().push(Tab {
id: 999,
url: "https://new.com".into(),
title: "New".into(),
active: false,
index: 1,
});
assert_eq!(state2.tabs.lock().unwrap().len(), 2);
}
#[test]
fn concurrent_access_storage() {
let state = Arc::new(ExtensionState::default());
let state2 = state.clone();
state
.storage_local
.lock()
.unwrap()
.insert("key".into(), "value".into());
assert_eq!(
state2.storage_local.lock().unwrap().get("key"),
Some(&"value".into())
);
}
#[test]
fn concurrent_access_messages() {
let state = Arc::new(ExtensionState::default());
let state2 = state.clone();
state.queue_message(PendingMessage {
data: Value::string("test"),
sender_origin: None,
is_external: false,
sender_tab_id: None,
});
let msgs = state2.take_messages();
assert_eq!(msgs.len(), 1);
}
#[test]
fn state_clone_shares_data() {
let state1 = ExtensionState::default();
let state2 = state1.clone();
state1.tabs.lock().unwrap().clear();
assert!(state2.tabs.lock().unwrap().is_empty());
assert!(state1.tabs.lock().unwrap().is_empty());
}
#[test]
fn tab_struct_all_fields() {
let tab = Tab {
id: 123,
url: "https://example.com/path".into(),
title: "Example Title".into(),
active: true,
index: 5,
};
assert_eq!(tab.id, 123);
assert_eq!(tab.url, "https://example.com/path");
assert_eq!(tab.title, "Example Title");
assert!(tab.active);
assert_eq!(tab.index, 5);
}
#[test]
fn tab_inactive() {
let tab = Tab {
id: 1,
url: "https://example.com".into(),
title: "Example".into(),
active: false,
index: 0,
};
assert!(!tab.active);
}
#[test]
fn cookie_struct_all_fields() {
let cookie = Cookie {
name: "session_id".into(),
value: "abc123xyz".into(),
domain: ".example.com".into(),
path: "/api".into(),
secure: true,
http_only: true,
};
assert_eq!(cookie.name, "session_id");
assert_eq!(cookie.value, "abc123xyz");
assert_eq!(cookie.domain, ".example.com");
assert_eq!(cookie.path, "/api");
assert!(cookie.secure);
assert!(cookie.http_only);
}
#[test]
fn cookie_non_secure() {
let cookie = Cookie {
name: "test".into(),
value: "value".into(),
domain: "example.com".into(),
path: "/".into(),
secure: false,
http_only: false,
};
assert!(!cookie.secure);
assert!(!cookie.http_only);
}
#[test]
fn alarm_struct_all_fields() {
let alarm = Alarm {
name: "alarm1".into(),
scheduled_time: 1234567890.5,
period_in_minutes: Some(5.0),
};
assert_eq!(alarm.name, "alarm1");
assert_eq!(alarm.scheduled_time, 1234567890.5);
assert_eq!(alarm.period_in_minutes, Some(5.0));
}
#[test]
fn alarm_one_time() {
let alarm = Alarm {
name: "once".into(),
scheduled_time: 1234567890.0,
period_in_minutes: None,
};
assert!(alarm.period_in_minutes.is_none());
}
#[test]
fn pending_message_all_fields() {
let msg = PendingMessage {
data: Value::json(r#"{"action": "test"}"#),
sender_origin: Some("https://sender.com".into()),
is_external: true,
sender_tab_id: Some(42),
};
assert_eq!(msg.data, Value::json(r#"{"action": "test"}"#));
assert_eq!(msg.sender_origin, Some("https://sender.com".into()));
assert!(msg.is_external);
assert_eq!(msg.sender_tab_id, Some(42));
}
#[test]
fn pending_message_minimal() {
let msg = PendingMessage {
data: Value::Null,
sender_origin: None,
is_external: false,
sender_tab_id: None,
};
assert_eq!(msg.data, Value::Null);
assert!(msg.sender_origin.is_none());
assert!(!msg.is_external);
assert!(msg.sender_tab_id.is_none());
}
}