leptos/projects/ory-kratos/e2e/tests/app_suite.rs

602 lines
23 KiB
Rust

#![feature(never_type)]
mod fixtures;
use anyhow::anyhow;
use anyhow::Result;
use chromiumoxide::cdp::browser_protocol::log::EventEntryAdded;
use chromiumoxide::cdp::js_protocol::runtime::EventConsoleApiCalled;
use chromiumoxide::{
browser::{Browser, BrowserConfig},
cdp::browser_protocol::{
network::{EventRequestWillBeSent, EventResponseReceived, Request, Response},
page::NavigateParams,
},
element::Element,
page::ScreenshotParams,
Page,
};
use cucumber::World;
use futures::channel::mpsc::Sender;
use futures_util::stream::StreamExt;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::RwLock;
use tokio_tungstenite::connect_async;
use uuid::Uuid;
static EMAIL_ID_MAP: Lazy<RwLock<HashMap<String, String>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
#[derive(Clone, Debug, PartialEq)]
pub struct RequestPair {
req: Option<Request>,
redirect_resp: Option<Response>,
resp: Option<Response>,
cookies_before_request: String,
cookies_after_response: String,
ts: std::time::Instant,
}
/*
let screenshot = world
.page
.screenshot(
ScreenshotParams::builder()
.capture_beyond_viewport(true)
.full_page(true)
.build(),
)
.await
.unwrap();
world.screenshots.push(screenshot);
*/
#[derive(Clone, Debug)]
pub enum CookieEnum {
BeforeReq(String),
AfterResp(String),
}
impl RequestPair {
pub fn to_string(&self) -> String {
let (top_req, req_headers) = if let Some(req) = &self.req {
(
format!("{} : {} \n", req.method, req.url,),
format!("{} :\n{:#?} \n", req.url, req.headers),
)
} else {
("NO REQ".to_string(), "NO REQ".to_string())
};
let (top_redirect_resp, _redirect_resp_headers) = if let Some(resp) = &self.redirect_resp {
(
format!("{} : {}", resp.status, resp.url),
format!("{} :\n {:#?}", resp.url, resp.headers),
)
} else {
("".to_string(), "".to_string())
};
let (top_resp, resp_headers) = if let Some(resp) = &self.resp {
(
format!("{} : {}", resp.status, resp.url),
format!("{} :\n {:#?}", resp.url, resp.headers),
)
} else {
("NO RESP".to_string(), "NO RESP".to_string())
};
format!(
"REQ: {}\n RESP: {}\n \n REDIRECT {} \n REQ_HEADERS: {} \n REQ_COOKIES: \n{}\n RESP_HEADERS:{} \n RESP_COOKIES: \n{}\n ",
top_req, top_resp,top_redirect_resp, req_headers, self.cookies_before_request,resp_headers,self.cookies_after_response
)
}
}
#[tokio::main]
async fn main() -> Result<()> {
// create a thread and store a
// tokio-tungstenite client that connectsto http://127.0.0.1:1080/ws
// and then stores the recieved messages in a once_cell::Lazy<RwLock<Vec<MailCrabMsg>>>
// or a custom struct that matches the body or has specific impls for verify codes, links etc.
let _ = tokio::spawn(async move {
let (mut socket, _) = connect_async(
url::Url::parse("ws://127.0.0.1:1080/ws").expect("Can't connect to case count URL"),
)
.await
.unwrap();
while let Some(msg) = socket.next().await {
if let Ok(tokio_tungstenite::tungstenite::Message::Text(text)) = msg {
let Email { id, to } = serde_json::from_str::<Email>(&text).unwrap();
let email = to[0].email.clone();
EMAIL_ID_MAP.write().await.insert(email, id.to_string());
}
}
});
AppWorld::cucumber()
.init_tracing()
.fail_on_skipped()
.max_concurrent_scenarios(1)
.fail_fast()
.before(|_feature, _rule, scenario, world| {
Box::pin(async move {
let screenshot_directory_name = format!("./screenshots/{}", scenario.name);
if let Ok(sc_dir) = std::fs::read_dir(&screenshot_directory_name) {
for file in sc_dir {
if let Ok(file) = file {
std::fs::remove_file(file.path()).unwrap();
}
}
} else {
std::fs::create_dir(&screenshot_directory_name).unwrap();
}
// take the page from world
// add network event listener, tracking requests and pairing them with responses
// store them somewhere inside of the world.
let page = world.page.clone();
let mut req_events = page
.event_listener::<EventRequestWillBeSent>()
.await
.unwrap();
let mut resp_events = page
.event_listener::<EventResponseReceived>()
.await
.unwrap();
world.page.enable_log().await.unwrap();
// get log events generated by the browser
let mut log_events = page.event_listener::<EventEntryAdded>().await.unwrap();
// get log events generated by leptos or other console.log() calls..
let mut runtime_events = page
.event_listener::<EventConsoleApiCalled>()
.await
.unwrap();
let console_logs = world.console_logs.clone();
let console_logs_2 = world.console_logs.clone();
tokio::task::spawn(async move {
while let Some(event) = log_events.next().await {
if let Some(EventEntryAdded { entry }) =
Arc::<EventEntryAdded>::into_inner(event) {
console_logs.write().await.push(format!(" {entry:#?} "));
} else {
tracing::error!("tried to into inner but none")
}
}
});
tokio::task::spawn(async move {
while let Some(event) = runtime_events.next().await {
if let Some(event) =Arc::<EventConsoleApiCalled>::into_inner(event) {
console_logs_2
.write()
.await
.push(format!(" CONSOLE_LOG: {:#?}", event.args));
} else {
tracing::error!("tried to into inner but none")
}
}
});
let (tx, mut rx) = futures::channel::mpsc::channel::<Option<CookieEnum>>(1000);
let mut tx_c = tx.clone();
let mut tx_c_2 = tx.clone();
world.cookie_sender = Some(tx);
let req_resp = world.req_resp.clone();
// Ideally you'd send the message for the Page to get the cookies from inside of the event stream loop,
// but for some reason that doesn't always work (but sometimes it does),
// but putting it in it's own thread makes it always work. Not sure why at the moment... ,
// something about async, about senders, about trying to close the browser but keeping senders around.
// we need to close the loop and drop the task to close the browser (I think)...
tokio::task::spawn(async move {
while let Some(some_request_id) = rx.next().await {
if let Some(cookie_enum) = some_request_id {
match cookie_enum {
CookieEnum::BeforeReq(req_id) => {
let cookies = page
.get_cookies()
.await
.unwrap_or_default()
.iter()
.map(|cookie| {
format!("name={}\n value={}", cookie.name, cookie.value)
})
.collect::<Vec<String>>()
.join("\n");
if let Some(thing) = req_resp
.write()
.await
.get_mut(&req_id) {
thing.cookies_before_request = cookies;
}
}
CookieEnum::AfterResp(req_id) => {
let cookies = page
.get_cookies()
.await
.unwrap_or_default()
.iter()
.map(|cookie| {
format!("name={}\n value={}", cookie.name, cookie.value)
})
.collect::<Vec<String>>()
.join("\n");
if let Some(thing) = req_resp
.write()
.await
.get_mut(&req_id) {
thing.cookies_after_response = cookies;
}
}
}
} else {
break;
}
}
});
let req_resp = world.req_resp.clone();
tokio::task::spawn(async move {
while let Some(event) = req_events.next().await {
if let Some(event) = Arc::<EventRequestWillBeSent>::into_inner(event) {
if event.request.url.contains("/pkg/") {
continue;
}
let req_id = event.request_id.inner().clone();
req_resp.write().await.insert(
req_id.clone(),
RequestPair {
req: Some(event.request),
redirect_resp: event.redirect_response,
resp: None,
cookies_before_request: "".to_string(),
cookies_after_response: "".to_string(),
ts: std::time::Instant::now(),
},
);
if let Err(msg) = tx_c.try_send(Some(CookieEnum::BeforeReq(req_id.clone()))) {
tracing::error!(" oopsies on the {msg:#?}");
}
} else {
tracing::error!("into inner err")
}
}
});
let req_resp = world.req_resp.clone();
tokio::task::spawn(async move {
while let Some(event) = resp_events.next().await {
if let Some(event) = Arc::<EventResponseReceived>::into_inner(event){
if event.response.url.contains("/pkg/") {
continue;
}
let req_id = event.request_id.inner().clone();
if let Err(msg) = tx_c_2
.try_send(Some(CookieEnum::AfterResp(req_id.clone()))) {
tracing::error!("err sending {msg:#?}");
}
if let Some(request_pair) = req_resp.write().await.get_mut(&req_id) {
request_pair.resp = Some(event.response);
} else {
req_resp.write().await.insert(
req_id.clone(),
RequestPair {
req: None,
redirect_resp: None,
resp: Some(event.response),
cookies_before_request: "No cookie?".to_string(),
cookies_after_response: "No cookie?".to_string(),
ts: std::time::Instant::now(),
},
);
}
} else {
tracing::error!(" uhh err here")
}
}
});
// We don't need to join on our join handles, they will run detached and clean up whenever.
})
})
.after(|_feature, _rule, scenario, ev, world| {
Box::pin(async move {
let screenshot_directory_name = format!("./screenshots/{}", scenario.name);
let world = world.unwrap();
// screenshot the last step
if let Ok(screenshot) = world
.page
.screenshot(
ScreenshotParams::builder()
.capture_beyond_viewport(true)
.full_page(true)
.build(),
)
.await {
world.screenshots.push(screenshot);
}
if let cucumber::event::ScenarioFinished::StepFailed(_, _, _) = ev {
// close the cookie task.
if world
.cookie_sender
.as_mut()
.unwrap()
.try_send(None).is_err() {
tracing::error!("can't close cookie sender");
}
// print any applicable screenshots (just the last one of the failed step if there was none taken during the scenario)
for (i, screenshot) in world.screenshots.iter().enumerate() {
// i.e ./screenshots/login/1.png
_ =std::fs::write(
screenshot_directory_name.clone()
+ "/"
+ i.to_string().as_str()
+ ".png",
screenshot,
);
}
// print network
let mut network_output = world
.req_resp
.read()
.await
.values()
.map(|val| val.clone())
.collect::<Vec<RequestPair>>();
network_output.sort_by(|a, b| a.ts.cmp(&b.ts));
let network_output = network_output
.into_iter()
.map(|val| val.to_string())
.collect::<Vec<String>>()
.join("\n");
_ = std::fs::write("./network_output", network_output.as_bytes());
let console_logs = world.console_logs.read().await.join("\n");
_ =std::fs::write("./console_logs", console_logs.as_bytes());
// print html
if let Ok(html) = world.page.content().await {
_ = std::fs::write("./html", html.as_bytes());
}
}
if let Err(err) = world.browser.close().await {
tracing::error!("{err:#?}");
}
if let Err(err) = world.browser.wait().await {
tracing::error!("{err:#?}");
}
})
})
.run_and_exit("./features")
.await;
Ok(())
}
#[tracing::instrument]
async fn build_browser() -> Result<Browser, Box<dyn std::error::Error>> {
let (browser, mut handler) = Browser::launch(
BrowserConfig::builder()
//.enable_request_intercept()
.disable_cache()
.request_timeout(Duration::from_secs(1))
//.with_head()
//.arg("--remote-debugging-port=9222")
.build()?,
)
.await?;
tokio::task::spawn(async move {
while let Some(h) = handler.next().await {
if h.is_err() {
tracing::info!("{h:?}");
break;
}
}
});
Ok(browser)
}
pub const HOST: &str = "https://127.0.0.1:3000";
#[derive(World)]
#[world(init = Self::new)]
pub struct AppWorld {
pub browser: Browser,
pub page: Page,
pub req_resp: Arc<RwLock<HashMap<String, RequestPair>>>,
pub clipboard: HashMap<&'static str, String>,
pub cookie_sender: Option<Sender<Option<CookieEnum>>>,
pub screenshots: Vec<Vec<u8>>,
pub console_logs: Arc<RwLock<Vec<String>>>,
}
impl std::fmt::Debug for AppWorld {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppWorld").finish()
}
}
impl AppWorld {
async fn new() -> Result<Self, anyhow::Error> {
let browser = build_browser().await.unwrap();
let page = browser.new_page("about:blank").await?;
Ok(Self {
browser,
page,
req_resp: Arc::new(RwLock::new(HashMap::new())),
clipboard: HashMap::new(),
cookie_sender: None,
screenshots: Vec::new(),
console_logs: Arc::new(RwLock::new(Vec::new())),
})
}
pub async fn errors(&mut self) -> Result<()> {
if let Ok(error) = self.find(ids::ERROR_ERROR_ID).await {
Err(anyhow!("{}", error.inner_text().await?.unwrap_or(String::from("no error in inner template?"))))
} else {
Ok(())
}
}
pub async fn find(&self, id: &'static str) -> Result<Element> {
for _ in 0..4 {
if let Ok(el) = self.page.find_element(format!("#{id}")).await {
return Ok(el);
}
crate::fixtures::wait().await;
}
Err(anyhow!("Can't find {id}"))
}
pub async fn find_submit(&mut self) -> Result<Element> {
for _ in 0..4 {
if let Ok(el) = self.page.find_element(format!("input[type=submit]")).await {
return Ok(el);
}
crate::fixtures::wait().await;
}
Err(anyhow!("Can't find input type=submit"))
}
/*pub async fn find_all(&mut self, id: &'static str) -> Result<ElementList> {
Ok(ElementList(
self.page.find_elements(format!("#{id}")).await?,
))
}*/
pub async fn goto_url(&mut self, url: &str) -> Result<()> {
self.page
.goto(
NavigateParams::builder()
.url(url)
.build()
.map_err(|err| anyhow!(err))?,
)
.await?
.wait_for_navigation()
.await?;
self.screenshot().await?;
Ok(())
}
pub async fn goto_path(&mut self, path: &str) -> Result<()> {
let url = format!("{}{}", HOST, path);
self.page
.goto(
NavigateParams::builder()
.url(url)
.build()
.map_err(|err| anyhow!(err))?,
)
.await?;
self.screenshot().await?;
Ok(())
}
pub async fn screenshot(&mut self) -> Result<()> {
let sc = self.page.screenshot(ScreenshotParams::default()).await?;
self.screenshots.push(sc);
Ok(())
}
pub async fn set_field<S: AsRef<str> + std::fmt::Display>(
&mut self,
id: &'static str,
value: S,
) -> Result<()> {
let element = self.find(id).await?;
element.focus().await?.type_str(value).await?;
self.screenshot().await?;
Ok(())
}
pub async fn click(&mut self, id: &'static str) -> Result<()> {
self.find(id).await?.click().await?;
Ok(())
}
#[tracing::instrument(err)]
pub async fn submit(&mut self) -> Result<()> {
self.screenshot().await?;
self.find_submit().await?.click().await?;
Ok(())
}
pub async fn find_text(&self, text: String) -> Result<Element> {
let selector: String = format!("//*[contains(text(), '{text}') or @*='{text}']");
let mut count = 0;
loop {
let result = self.page.find_xpath(&selector).await;
if result.is_err() && count < 4 {
count += 1;
crate::fixtures::wait().await;
} else {
let result = result?;
return Ok(result);
}
}
}
pub async fn url_contains(&self, s: &'static str) -> Result<()> {
if let Some(current) = self.page.url().await? {
if !current.contains(s) {
return Err(anyhow!("{current} does not contains {s}"));
}
} else {
return Err(anyhow!("NO CURRENT URL FOUND"));
}
Ok(())
}
pub async fn verify_route(&self, path: &'static str) -> Result<()> {
let url = format!("{}{}", HOST, path);
if let Some(current) = self.page.url().await? {
if current != url {
return Err(anyhow!(
"EXPECTING ROUTE: {path}\n but FOUND:\n {current:#?}"
));
}
} else {
return Err(anyhow!(
"EXPECTING ROUTE: {path}\n but NO CURRENT URL FOUND"
));
}
Ok(())
}
}
/*
#[derive(Debug)]
pub struct ElementList(Vec<Element>);
impl ElementList {
/// iterates over elements, finds first element whose text (as rendered) contains text given as function's argument.
pub async fn find_by_text(&self,text:&'static str) -> Result<Element> {
for element in self.0.iter() {
if let Ok(Some(inner_text)) = element.inner_text().await {
if inner_text.contains(text) {
return Ok(element);
}
}
}
Err(anyhow!(format!("given text {} no element found",text)))
}
}*/
#[derive(Serialize, Deserialize, Debug)]
struct Email {
id: Uuid,
to: Vec<Recipient>,
}
#[derive(Serialize, Deserialize, Debug)]
struct Recipient {
name: Option<String>,
email: String,
}