use super::{ConnectionMetadata, ProbeStatus, cdp::Cdp, chat_id, valid_body, valid_id};
use crate::config::DesktopAccount;
use crate::error::AppError;
use serde_json::{Value, json};
const READ_SCRIPT: &str = include_str!("../desktop.js");
const SEND_SCRIPT: &str = include_str!("../desktop_send.js");
const CACHE_SCRIPT: &str = include_str!("../desktop_cache.js");
const READ_JSON: &str = r#"(value => JSON.stringify(value, (_key, item) =>
typeof item === 'string' ? item.toWellFormed() : item))"#;
pub trait Verifier: Send + Sync {
fn verify(
&self,
port: u16,
) -> impl std::future::Future<Output = Result<(ConnectionMetadata, Vec<Value>), AppError>> + Send;
}
pub struct Session<S> {
system: S,
page: Option<(u16, Cdp, bool, DesktopAccount)>,
}
impl<S: Verifier> Session<S> {
pub fn new(system: S) -> Self {
Self { system, page: None }
}
pub async fn probe(&self, port: u16) -> Result<ConnectionMetadata, AppError> {
valid_port(port)?;
Ok(self.system.verify(port).await?.0)
}
pub async fn read(&mut self, request: &Value) -> Result<Value, AppError> {
let (port, params) = read_params(request)?;
let account = DesktopAccount::from_request(&request["account_type"])?;
if let Some((connected, page, personal, scope)) = &mut self.page
&& *connected == port
&& *scope == account
&& let Ok(result) = page.evaluate(&read_expression(¶ms, *personal)).await
&& !origin_changed(&result)
{
return Ok(json!({"result": result}));
}
self.page = None;
let Ok((metadata, targets)) = self.system.verify(port).await else {
return Ok(refusal(ProbeStatus::MetadataProbeFailed));
};
if metadata.status != ProbeStatus::MetadataOk {
return Ok(refusal(metadata.status));
}
let candidates = match selected_pages(&targets, account) {
Ok(pages) => pages,
Err(refused) => return Ok(refused),
};
let Some(target) = candidates.into_iter().next() else {
return Ok(json!({"error": "no_target"}));
};
let Ok(mut page) = Cdp::connect(port, target.id).await else {
return Ok(json!({"error": "read_failed"}));
};
match page
.evaluate(&read_expression(¶ms, target.personal))
.await
{
Ok(result) => {
if origin_changed(&result) {
return Ok(json!({"error": "no_target"}));
}
self.page = Some((port, page, target.personal, account));
Ok(json!({"result": result}))
}
Err(_) => Ok(json!({"error": "read_failed"})),
}
}
pub async fn send_check(&self, port: u16, account: DesktopAccount) -> Result<Value, AppError> {
valid_port(port)?;
Ok(match self.main_page(port, account).await {
Ok(_) => json!({"ok": true}),
Err(refused) => refused,
})
}
pub async fn send(&self, request: &Value) -> Result<Value, AppError> {
let (port, params) = send_params(request)?;
let account = DesktopAccount::from_request(&request["account_type"])?;
let (mut page, personal) = match self.main_page(port, account).await {
Ok(page) => page,
Err(refused) => return Ok(refused),
};
Ok(
match page
.evaluate(&scoped_expression(
&format!("({SEND_SCRIPT})({params},({CACHE_SCRIPT})).then(JSON.stringify)"),
personal,
))
.await
{
Ok(result) => json!({"result": result}),
Err(_) => json!({"error": "unconfirmed"}),
},
)
}
async fn main_page(&self, port: u16, account: DesktopAccount) -> Result<(Cdp, bool), Value> {
let Ok((metadata, targets)) = self.system.verify(port).await else {
return Err(refusal(ProbeStatus::MetadataProbeFailed));
};
if metadata.status != ProbeStatus::MetadataOk {
return Err(refusal(metadata.status));
}
let candidates = selected_pages(&targets, account)?;
if candidates.is_empty() {
return Err(json!({"error": "no_target"}));
}
let probe = format!("({SEND_SCRIPT})({{\"probe\":true}}).then(JSON.stringify)");
for target in candidates {
let Ok(mut page) = Cdp::connect(port, target.id).await else {
continue;
};
let main = page
.evaluate(&scoped_expression(&probe, target.personal))
.await
.is_ok_and(|result| result["result"]["value"].as_str() == Some(r#"{"main":true}"#));
if main {
return Ok((page, target.personal));
}
}
Err(json!({"error": "layout"}))
}
}
fn read_expression(params: &Value, personal: bool) -> String {
let mut params = params.clone();
params["personal"] = json!(personal);
scoped_expression(
&format!("({READ_SCRIPT})({params},({CACHE_SCRIPT})).then({READ_JSON})"),
personal,
)
}
fn scoped_expression(expression: &str, personal: bool) -> String {
let origins = if personal {
json!(["https://teams.live.com"])
} else {
json!([
"https://teams.microsoft.com",
"https://teams.cloud.microsoft"
])
};
format!(
"({origins}.includes(globalThis.location?.origin)?({expression}):JSON.stringify({{error:'no_target'}}))"
)
}
fn origin_changed(result: &Value) -> bool {
result["result"]["value"].as_str() == Some(r#"{"error":"no_target"}"#)
}
fn send_params(request: &Value) -> Result<(u16, Value), AppError> {
let invalid = || AppError::InvalidInput("invalid desktop send request".into());
let port = request["port"]
.as_u64()
.and_then(|p| u16::try_from(p).ok())
.ok_or_else(invalid)?;
valid_port(port)?;
let chat = request["chat_id"]
.as_str()
.filter(|chat| chat_id(chat))
.ok_or_else(invalid)?;
let content = request["body"]
.as_str()
.filter(|body| valid_body(body))
.ok_or_else(invalid)?;
let id = request["client_message_id"]
.as_str()
.filter(|id| id.len() == 18 && id.bytes().all(|b| b.is_ascii_digit()))
.ok_or_else(invalid)?;
Ok((
port,
json!({"chat": chat, "content": content, "clientMessageId": id}),
))
}
fn refusal(status: ProbeStatus) -> Value {
json!({"error": status})
}
fn valid_port(port: u16) -> Result<(), AppError> {
if port < 1024 {
return Err(AppError::InvalidInput(
"desktop port must be between 1024 and 65535".into(),
));
}
Ok(())
}
fn read_params(request: &Value) -> Result<(u16, Value), AppError> {
let invalid = || AppError::InvalidInput("invalid desktop read request".into());
let port = request["port"]
.as_u64()
.and_then(|p| u16::try_from(p).ok())
.ok_or_else(invalid)?;
valid_port(port)?;
let query = request["query"].as_str().ok_or_else(invalid)?;
let limit = request["limit"]
.as_u64()
.filter(|l| (1..=200).contains(l))
.ok_or_else(invalid)?;
let chat = match (query, request["chat_id"].as_str()) {
("chats", None) => "",
("messages", Some(chat)) if chat_id(chat) => chat,
_ => return Err(invalid()),
};
let after = match &request["after"] {
Value::Null => Value::Null,
after => {
let t = after["t"]
.as_i64()
.filter(|t| *t >= 0)
.ok_or_else(invalid)?;
let id = after["id"]
.as_str()
.filter(|id| valid_id(id))
.ok_or_else(invalid)?;
json!({"t": t, "id": id})
}
};
Ok((
port,
json!({"query": query, "chat": chat, "limit": limit, "after": after}),
))
}
#[derive(Debug, PartialEq)]
struct TeamsPage<'a> {
id: &'a str,
personal: bool,
}
fn teams_pages(targets: &[Value]) -> Vec<TeamsPage<'_>> {
targets
.iter()
.filter_map(|target| {
let url = url::Url::parse(target["url"].as_str()?).ok()?;
let id = target["id"].as_str()?;
let host = url.host_str()?;
(target["type"] == "page"
&& url.scheme() == "https"
&& url.port_or_known_default() == Some(443)
&& url.username().is_empty()
&& url.password().is_none()
&& matches!(
host,
"teams.microsoft.com" | "teams.cloud.microsoft" | "teams.live.com"
)
&& (8..=64).contains(&id.len())
&& id.bytes().all(|b| b.is_ascii_hexdigit()))
.then_some(TeamsPage {
id,
personal: host == "teams.live.com",
})
})
.collect()
}
fn selected_pages(targets: &[Value], account: DesktopAccount) -> Result<Vec<TeamsPage<'_>>, Value> {
let pages: Vec<_> = teams_pages(targets)
.into_iter()
.filter(|page| match account {
DesktopAccount::Auto => true,
DesktopAccount::Personal => page.personal,
DesktopAccount::WorkSchool => !page.personal,
})
.collect();
if pages.iter().any(|page| page.personal) && pages.iter().any(|page| !page.personal) {
return Err(json!({"error": "account_ambiguous"}));
}
Ok(pages)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::desktop::{
MAX_BODY, cdp::tests::devtools_server, owner::tests::FakeSystem, read_request,
};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
#[test]
fn read_json_repairs_unpaired_utf16_and_preserves_valid_text() {
let output = std::process::Command::new("node")
.args([
"-e",
&format!(
r#"const value={{items:[{{topic:'a\ud800b\udc00c',body:'🙂 é 中文',literal:'\\ud800',nested:['\ud83d\ude42','\ud800\ud800\udc00']}}]}};
console.log(JSON.stringify({{raw:JSON.stringify(value),clean:({READ_JSON})(value)}}));"#
),
])
.output()
.expect("Node.js 20 or newer is required for desktop script tests");
assert!(output.status.success());
let result: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(serde_json::from_str::<Value>(result["raw"].as_str().unwrap()).is_err());
let clean: Value = serde_json::from_str(result["clean"].as_str().unwrap()).unwrap();
assert_eq!(
clean,
json!({"items":[{"topic":"a�b�c","body":"🙂 é 中文","literal":"\\ud800","nested":["🙂","�𐀀"]}]})
);
}
#[test]
fn current_origin_guard_blocks_other_surfaces_and_lookalikes_before_evaluation() {
for (personal, origin, allowed) in [
(true, "https://teams.live.com", true),
(true, "https://teams.microsoft.com", false),
(true, "https://teams.live.com.evil.example", false),
(false, "https://teams.microsoft.com", true),
(false, "https://teams.cloud.microsoft", true),
(false, "https://teams.live.com", false),
(false, "http://teams.microsoft.com", false),
] {
let expression =
scoped_expression("(++globalThis.calls,JSON.stringify({ran:true}))", personal);
let output = std::process::Command::new("node").args([
"-e",
"const vm=require('node:vm');const context={location:{origin:process.argv[1]},calls:0};const value=vm.runInNewContext(process.argv[2],context);console.log(JSON.stringify({value:JSON.parse(value),calls:context.calls}));",
origin, &expression,
]).output().expect("Node.js is required for desktop script tests");
assert!(output.status.success());
let result: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(result["calls"], if allowed { 1 } else { 0 });
assert_eq!(
result["value"],
if allowed {
json!({"ran":true})
} else {
json!({"error":"no_target"})
}
);
}
}
#[tokio::test]
async fn navigated_cached_pages_are_dropped_and_verified_again_before_reading() {
let evaluations = Arc::new(AtomicUsize::new(0));
let counter = evaluations.clone();
let port = devtools_server(
json!([{"type":"page","url":"https://teams.live.com/v2/","id":"ABCDEF12"}]),
move |request| {
let value = if counter.fetch_add(1, Ordering::SeqCst) == 1 {
r#"{"error":"no_target"}"#
} else {
"{}"
};
Some(
json!({"id":request["id"],"result":{"result":{"type":"string","value":value}}})
.to_string(),
)
},
)
.await;
let mut session = Session::new(FakeSystem::teams());
let request = read_request(port, None, 1, None).unwrap();
for _ in 0..2 {
assert_eq!(
session.read(&request).await.unwrap()["result"]["result"]["value"],
"{}"
);
}
assert_eq!(evaluations.load(Ordering::SeqCst), 3);
}
#[test]
fn read_params_are_revalidated() {
let (port, params) = read_params(&read_request(9222, None, 5, None).unwrap()).unwrap();
assert_eq!(port, 9222);
assert_eq!(
params,
json!({"query": "chats", "chat": "", "limit": 5, "after": null})
);
let base = read_request(9222, Some("19:a@thread.v2"), 5, None).unwrap();
assert_eq!(read_params(&base).unwrap().1["chat"], "19:a@thread.v2");
for (key, value) in [
("port", json!(80)),
("port", json!(70000)),
("query", json!("teams")),
("limit", json!(0)),
("limit", json!(201)),
("chat_id", json!("19:a'); x")),
("chat_id", Value::Null),
("after", json!({"t": -1, "id": "x"})),
("after", json!({"t": 1, "id": "a b"})),
] {
let mut request = base.clone();
request[key] = value;
assert!(read_params(&request).is_err(), "{key}");
}
}
#[test]
fn only_teams_pages_with_hex_ids_are_selected() {
let targets = [
json!({"type": "service_worker", "url": "https://teams.microsoft.com/", "id": "AAAAAAAA"}),
json!({"type": "page", "url": "https://teams.microsoft.com.evil.example/", "id": "BBBBBBBB"}),
json!({"type": "page", "url": "https://teams.microsoft.com/v2/", "id": "../../x"}),
json!({"type": "page", "url": "https://teams.cloud.microsoft/v2/", "id": "CCCCCCCC"}),
];
assert_eq!(
teams_pages(&targets),
[TeamsPage {
id: "CCCCCCCC",
personal: false
}]
);
assert!(teams_pages(&targets[..3]).is_empty());
}
#[test]
fn personal_pages_require_the_exact_secure_origin_and_safe_ids() {
let targets = [
json!({"type":"page","url":"https://teams.live.com/v2/","id":"AAAAAAAA"}),
json!({"type":"page","url":"https://teams.live.com:443","id":"BBBBBBBB"}),
json!({"type":"page","url":"https://teams.live.com.evil.example/v2/","id":"CCCCCCCC"}),
json!({"type":"page","url":"http://teams.live.com/v2/","id":"DDDDDDDD"}),
json!({"type":"page","url":"https://teams.live.com:444/v2/","id":"EEEEEEEE"}),
json!({"type":"page","url":"https://teams.live.com@evil.example/v2/","id":"FFFFFFFF"}),
json!({"type":"page","url":"https://evil.example@teams.live.com/v2/","id":"11111111"}),
json!({"type":"page","url":"https://teams.live.com/v2/","id":"../../x"}),
json!({"type":"service_worker","url":"https://teams.live.com/v2/","id":"22222222"}),
];
assert_eq!(
teams_pages(&targets),
[
TeamsPage {
id: "AAAAAAAA",
personal: true
},
TeamsPage {
id: "BBBBBBBB",
personal: true
},
]
);
}
#[tokio::test]
async fn personal_reads_keep_the_identity_context_when_reusing_the_page() {
let evaluations = Arc::new(AtomicUsize::new(0));
let counter = evaluations.clone();
let port = devtools_server(
json!([{"type":"page","url":"https://teams.live.com/v2/","id":"ABCDEF12"}]),
move |request| {
counter.fetch_add(1, Ordering::SeqCst);
assert!(
request["params"]["expression"]
.as_str()
.unwrap()
.contains(r#""personal":true"#)
);
Some(
json!({"id":request["id"],"result":{"result":{"type":"string","value":"{}"}}})
.to_string(),
)
},
)
.await;
let mut session = Session::new(FakeSystem::teams());
for _ in 0..2 {
assert_eq!(
session
.read(&read_request(port, None, 5, None).unwrap())
.await
.unwrap()["result"]["result"]["value"],
"{}"
);
}
assert_eq!(evaluations.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn mixed_personal_and_work_pages_are_refused_before_evaluation() {
let evaluations = Arc::new(AtomicUsize::new(0));
let counter = evaluations.clone();
let port = devtools_server(
json!([
{"type":"page","url":"https://teams.live.com/v2/","id":"AAAAAAAA"},
{"type":"page","url":"https://teams.microsoft.com/v2/","id":"BBBBBBBB"},
]),
move |_| {
counter.fetch_add(1, Ordering::SeqCst);
None
},
)
.await;
let mut session = Session::new(FakeSystem::teams());
let refused = json!({"error":"account_ambiguous"});
assert_eq!(
session
.read(&read_request(port, None, 5, None).unwrap())
.await
.unwrap(),
refused
);
assert_eq!(
session
.send_check(port, DesktopAccount::Auto)
.await
.unwrap(),
refused
);
assert_eq!(session.send(&send_request(port)).await.unwrap(), refused);
assert_eq!(evaluations.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn explicit_account_selection_filters_pages_and_revalidates_cached_read_scope() {
let targets = json!([
{"type":"page","url":"https://teams.microsoft.com/v2/","id":"AAAAAAAA"},
{"type":"page","url":"https://teams.live.com/v2/","id":"BBBBBBBB"},
]);
let selected =
selected_pages(targets.as_array().unwrap(), DesktopAccount::Personal).unwrap();
assert_eq!(
selected,
[TeamsPage {
id: "BBBBBBBB",
personal: true
}]
);
assert_eq!(
selected_pages(targets.as_array().unwrap(), DesktopAccount::WorkSchool).unwrap(),
[TeamsPage {
id: "AAAAAAAA",
personal: false
}]
);
let evaluations = Arc::new(AtomicUsize::new(0));
let counter = evaluations.clone();
let port = devtools_server(targets, move |request| {
let expression = request["params"]["expression"].as_str().unwrap();
if expression.contains("\"query\":\"chats\"") {
let personal = counter.fetch_add(1, Ordering::SeqCst) == 0;
assert!(expression.contains(if personal {
"\"personal\":true"
} else {
"\"personal\":false"
}));
}
let value = if expression.contains("\"probe\":true") {
"{\"main\":true}"
} else {
"{}"
};
Some(
json!({"id":request["id"],"result":{"result":{"type":"string","value":value}}})
.to_string(),
)
})
.await;
let mut session = Session::new(FakeSystem::teams());
for account in [DesktopAccount::Personal, DesktopAccount::WorkSchool] {
let mut request = read_request(port, None, 1, None).unwrap();
request["account_type"] = json!(account);
assert!(
session
.read(&request)
.await
.unwrap()
.get("result")
.is_some()
);
assert_eq!(
session.send_check(port, account).await.unwrap(),
json!({"ok":true})
);
let mut send = send_request(port);
send["account_type"] = json!(account);
assert!(session.send(&send).await.unwrap().get("result").is_some());
}
assert_eq!(evaluations.load(Ordering::SeqCst), 2);
let mut invalid = read_request(port, None, 1, None).unwrap();
invalid["account_type"] = json!("all");
assert!(session.read(&invalid).await.is_err());
let mut invalid = send_request(port);
invalid["account_type"] = json!(true);
assert!(session.send(&invalid).await.is_err());
assert_eq!(evaluations.load(Ordering::SeqCst), 2);
let work = [json!({"type":"page","url":"https://teams.microsoft.com/v2/","id":"AAAAAAAA"})];
assert!(
selected_pages(&work, DesktopAccount::Personal)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn reads_reuse_the_page_and_reverify_after_a_failure() {
let evaluations = Arc::new(AtomicUsize::new(0));
let counter = evaluations.clone();
let port = devtools_server(
json!([{"type": "page", "url": "https://teams.microsoft.com/v2/", "id": "ABCDEF12"}]),
move |request| {
let n = counter.fetch_add(1, Ordering::SeqCst);
let expression = request["params"]["expression"].as_str().unwrap();
assert!(expression.contains(r#""query":"chats""#));
(n != 1).then(|| {
json!({"id": request["id"], "result": {"result": {"type": "string", "value": "{}"}}})
.to_string()
})
},
)
.await;
let mut session = Session::new(FakeSystem::teams());
let request = read_request(port, None, 5, None).unwrap();
for _ in 0..3 {
let reply = session.read(&request).await.unwrap();
assert_eq!(reply["result"]["result"]["value"], "{}");
}
assert_eq!(evaluations.load(Ordering::SeqCst), 4);
assert!(session.probe(port).await.unwrap().endpoint_available());
}
#[tokio::test]
async fn refusals_are_reported_as_codes() {
let port = devtools_server(json!([]), |_| None).await;
let request = read_request(port, None, 5, None).unwrap();
let mut session = Session::new(FakeSystem::teams());
assert_eq!(
session.read(&request).await.unwrap(),
json!({"error": "no_target"})
);
let mut session = Session::new(FakeSystem {
trusted: vec![],
..FakeSystem::teams()
});
assert_eq!(
session.read(&request).await.unwrap(),
json!({"error": "non_teams_listener_refused"})
);
}
fn send_request(port: u16) -> Value {
crate::desktop::send_request(port, "19:a@thread.v2", "hi <b>\nthere").unwrap()
}
#[test]
fn send_params_are_revalidated() {
let base = send_request(9222);
let (port, params) = send_params(&base).unwrap();
assert_eq!(port, 9222);
assert_eq!(params["chat"], "19:a@thread.v2");
assert_eq!(params["content"], "hi <b>\nthere");
assert_eq!(params["clientMessageId"].as_str().unwrap().len(), 18);
for (key, value) in [
("port", json!(80)),
("chat_id", json!("19:a'); x")),
("chat_id", json!("48:other")),
("body", json!(" \n")),
("body", json!("a\0b")),
("body", json!("x".repeat(MAX_BODY + 1))),
("body", Value::Null),
("client_message_id", json!("12345")),
("client_message_id", json!("12345678901234567x")),
] {
let mut request = base.clone();
request[key] = value;
assert!(send_params(&request).is_err(), "{key}");
}
}
async fn teams_window(sends: Arc<AtomicUsize>, answer_send: bool, origin: &str) -> u16 {
devtools_server(
json!([
{"type": "page", "url": format!("{origin}/v2/"), "id": "AAAAAAAA"},
{"type": "page", "url": format!("{origin}/v2/"), "id": "BBBBBBBB"},
{"type": "page", "url": format!("{origin}/v2/"), "id": "CCCCCCCC"},
]),
move |request| {
let expression = request["params"]["expression"].as_str().unwrap();
assert!(expression.contains("autoSendMessage"));
let value = if expression.contains(r#"({"probe":true})"#) {
if sends.fetch_add(1000, Ordering::SeqCst) / 1000 == 1 {
r#"{"main":true}"#
} else {
r#"{"main":false}"#
}
} else {
assert!(expression.contains(r#""chat":"19:a@thread.v2""#));
sends.fetch_add(1, Ordering::SeqCst);
if !answer_send {
return None;
}
r#"{"id":"1700000000000"}"#
};
Some(json!({"id": request["id"], "result": {"result": {"type": "string", "value": value}}}).to_string())
},
)
.await
}
#[tokio::test]
async fn sends_go_to_the_window_with_the_client_and_run_once() {
for origin in ["https://teams.microsoft.com", "https://teams.live.com"] {
let sends = Arc::new(AtomicUsize::new(0));
let port = teams_window(sends.clone(), true, origin).await;
let session = Session::new(FakeSystem::teams());
assert_eq!(
session
.send_check(port, DesktopAccount::Auto)
.await
.unwrap(),
json!({"ok": true})
);
sends.store(0, Ordering::SeqCst);
let reply = session.send(&send_request(port)).await.unwrap();
assert_eq!(
reply["result"]["result"]["value"],
r#"{"id":"1700000000000"}"#
);
assert_eq!(sends.load(Ordering::SeqCst), 2001);
}
}
#[tokio::test]
async fn a_lost_send_answer_is_unconfirmed_and_not_retried() {
let sends = Arc::new(AtomicUsize::new(0));
let port = teams_window(sends.clone(), false, "https://teams.microsoft.com").await;
let session = Session::new(FakeSystem::teams());
assert_eq!(
session.send(&send_request(port)).await.unwrap(),
json!({"error": "unconfirmed"})
);
assert_eq!(sends.load(Ordering::SeqCst) % 1000, 1);
}
#[tokio::test]
async fn sends_are_refused_without_a_verified_window() {
let untrusted = Session::new(FakeSystem {
trusted: vec![],
..FakeSystem::teams()
});
let sends = Arc::new(AtomicUsize::new(0));
let port = teams_window(sends.clone(), true, "https://teams.microsoft.com").await;
assert_eq!(
untrusted.send(&send_request(port)).await.unwrap(),
json!({"error": "non_teams_listener_refused"})
);
assert_eq!(sends.load(Ordering::SeqCst), 0);
let empty = devtools_server(json!([]), |_| None).await;
let session = Session::new(FakeSystem::teams());
assert_eq!(
session
.send_check(empty, DesktopAccount::Auto)
.await
.unwrap(),
json!({"error": "no_target"})
);
let none = devtools_server(
json!([{"type": "page", "url": "https://teams.microsoft.com/v2/", "id": "AAAAAAAA"}]),
|request| {
Some(json!({"id": request["id"], "result": {"result": {"type": "string", "value": r#"{"main":false}"#}}}).to_string())
},
)
.await;
assert_eq!(
session.send(&send_request(none)).await.unwrap(),
json!({"error": "layout"})
);
}
}