use std::collections::HashMap;
use std::sync::{Arc, Weak};
use serde_json::{Value, json};
use tokio::sync::OnceCell;
use zendriver_transport::SessionHandle;
use crate::error::{Result, ZendriverError};
use crate::tab::TabInner;
#[derive(Clone, Debug)]
pub struct Storage {
inner: Arc<StorageInner>,
}
#[derive(Debug)]
struct StorageInner {
session: SessionHandle,
is_local: bool,
tab: Weak<TabInner>,
enable_once: OnceCell<()>,
}
impl Storage {
#[must_use]
pub(crate) fn new(session: SessionHandle, is_local: bool, tab: Weak<TabInner>) -> Self {
Self {
inner: Arc::new(StorageInner {
session,
is_local,
tab,
enable_once: OnceCell::new(),
}),
}
}
#[must_use]
pub fn is_local(&self) -> bool {
self.inner.is_local
}
pub async fn get(&self, key: &str) -> Result<Option<String>> {
let all = self.get_all().await?;
Ok(all.get(key).cloned())
}
pub async fn get_all(&self) -> Result<HashMap<String, String>> {
self.ensure_enabled().await?;
let tab = self.upgraded_tab()?;
let storage_id = self.build_storage_id(&tab).await?;
let resp = self
.inner
.session
.call(
"DOMStorage.getDOMStorageItems",
json!({ "storageId": storage_id }),
)
.await?;
drop(tab);
parse_entries(&resp)
}
pub async fn set(&self, key: &str, value: &str) -> Result<()> {
self.ensure_enabled().await?;
let tab = self.upgraded_tab()?;
let storage_id = self.build_storage_id(&tab).await?;
self.inner
.session
.call(
"DOMStorage.setDOMStorageItem",
json!({
"storageId": storage_id,
"key": key,
"value": value,
}),
)
.await?;
drop(tab);
Ok(())
}
pub async fn set_many(&self, items: &HashMap<String, String>) -> Result<()> {
for (k, v) in items {
self.set(k, v).await?;
}
Ok(())
}
pub async fn remove(&self, key: &str) -> Result<()> {
self.ensure_enabled().await?;
let tab = self.upgraded_tab()?;
let storage_id = self.build_storage_id(&tab).await?;
self.inner
.session
.call(
"DOMStorage.removeDOMStorageItem",
json!({ "storageId": storage_id, "key": key }),
)
.await?;
drop(tab);
Ok(())
}
pub async fn clear(&self) -> Result<()> {
self.ensure_enabled().await?;
let tab = self.upgraded_tab()?;
let storage_id = self.build_storage_id(&tab).await?;
self.inner
.session
.call("DOMStorage.clear", json!({ "storageId": storage_id }))
.await?;
drop(tab);
Ok(())
}
pub async fn len(&self) -> Result<usize> {
Ok(self.get_all().await?.len())
}
async fn ensure_enabled(&self) -> Result<()> {
self.inner
.enable_once
.get_or_try_init(|| async {
self.inner
.session
.call("DOMStorage.enable", json!({}))
.await?;
Ok::<(), ZendriverError>(())
})
.await?;
Ok(())
}
fn upgraded_tab(&self) -> Result<crate::tab::Tab> {
let tab_inner = self
.inner
.tab
.upgrade()
.ok_or_else(|| ZendriverError::Storage("owning tab has been dropped".into()))?;
Ok(crate::tab::Tab { inner: tab_inner })
}
async fn build_storage_id(&self, tab: &crate::tab::Tab) -> Result<Value> {
let url = tab.url().await?;
let origin = url.origin().ascii_serialization();
Ok(json!({
"securityOrigin": origin,
"isLocalStorage": self.inner.is_local,
}))
}
}
#[allow(clippy::result_large_err)] fn parse_entries(resp: &Value) -> Result<HashMap<String, String>> {
let arr = resp
.get("entries")
.and_then(|v| v.as_array())
.ok_or_else(|| ZendriverError::Storage("response missing `entries` array".into()))?;
let mut out = HashMap::with_capacity(arr.len());
for entry in arr {
let pair = entry.as_array().ok_or_else(|| {
ZendriverError::Storage("`entries` element is not a [key, value] array".into())
})?;
if pair.len() < 2 {
return Err(ZendriverError::Storage(
"`entries` element has fewer than 2 fields".into(),
));
}
let k = pair[0]
.as_str()
.ok_or_else(|| ZendriverError::Storage("entry key is not a string".into()))?
.to_string();
let v = pair[1]
.as_str()
.ok_or_else(|| ZendriverError::Storage("entry value is not a string".into()))?
.to_string();
out.insert(k, v);
}
Ok(out)
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
use zendriver_transport::testing::MockConnection;
use crate::tab::Tab;
#[tokio::test]
async fn get_enables_domstorage_then_reads_with_local_flag() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess.clone());
let storage = Storage::new(sess, true, Arc::downgrade(&tab.inner));
let fut = tokio::spawn({
let s = storage.clone();
async move { s.get("theme").await }
});
let id_enable = mock.expect_cmd("DOMStorage.enable").await;
mock.reply(id_enable, json!({})).await;
let id_info = mock.expect_cmd("Target.getTargetInfo").await;
mock.reply(
id_info,
json!({ "targetInfo": { "url": "https://x.test/page" } }),
)
.await;
let id_get = mock.expect_cmd("DOMStorage.getDOMStorageItems").await;
let sent = mock.last_sent();
assert_eq!(
sent["params"]["storageId"]["securityOrigin"],
"https://x.test"
);
assert_eq!(sent["params"]["storageId"]["isLocalStorage"], true);
mock.reply(
id_get,
json!({ "entries": [["theme", "dark"], ["lang", "en"]] }),
)
.await;
let got = fut.await.unwrap().unwrap();
assert_eq!(got, Some("dark".to_string()));
conn.shutdown();
}
#[tokio::test]
async fn set_dispatches_set_dom_storage_item_with_key_and_value() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess.clone());
let storage = Storage::new(sess, false, Arc::downgrade(&tab.inner));
let fut = tokio::spawn({
let s = storage.clone();
async move { s.set("token", "abc123").await }
});
let id_enable = mock.expect_cmd("DOMStorage.enable").await;
mock.reply(id_enable, json!({})).await;
let id_info = mock.expect_cmd("Target.getTargetInfo").await;
mock.reply(
id_info,
json!({ "targetInfo": { "url": "https://x.test/page" } }),
)
.await;
let id_set = mock.expect_cmd("DOMStorage.setDOMStorageItem").await;
let sent = mock.last_sent();
assert_eq!(
sent["params"]["storageId"]["securityOrigin"],
"https://x.test"
);
assert_eq!(sent["params"]["storageId"]["isLocalStorage"], false);
assert_eq!(sent["params"]["key"], "token");
assert_eq!(sent["params"]["value"], "abc123");
mock.reply(id_set, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
}