use std::sync::Arc;
use std::time::Duration;
use devpage_core::external::{Ask, Background, External, Outcome, Waits};
use futures::future::BoxFuture;
use crate::changes::Changes;
pub fn external(changes: Option<Changes>) -> Arc<External> {
External::new(
Arc::new(Http::new()),
Arc::new(Tokio),
Waits::default(),
changes.map(|changes| Arc::new(changes) as _),
)
}
pub fn offline() -> Arc<External> {
External::new(
Arc::new(Http::new()),
Arc::new(Nowhere),
Waits::default(),
None,
)
}
struct Nowhere;
impl Background for Nowhere {
fn spawn(&self, _: BoxFuture<'static, ()>) -> bool {
false
}
fn sleep(&self, pause: Duration) -> BoxFuture<'static, ()> {
Box::pin(tokio::time::sleep(pause))
}
}
pub struct Tokio;
impl Background for Tokio {
fn spawn(&self, task: BoxFuture<'static, ()>) -> bool {
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return false;
};
runtime.spawn(task);
true
}
fn sleep(&self, pause: Duration) -> BoxFuture<'static, ()> {
Box::pin(tokio::time::sleep(pause))
}
}
pub struct Http(reqwest::Client);
impl Http {
pub fn new() -> Self {
Http(
reqwest::Client::builder()
.user_agent(concat!("devpage/", env!("CARGO_PKG_VERSION")))
.default_headers(reqwest::header::HeaderMap::from_iter([(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("text/html,*/*;q=0.8"),
)]))
.build()
.expect("an HTTP client"),
)
}
}
impl Ask for Http {
fn ask<'a>(&'a self, address: &'a str, wait: Duration) -> BoxFuture<'a, Outcome> {
Box::pin(async move {
match self.0.get(address).timeout(wait).send().await {
Ok(response) if response.status().as_u16() < 400 => Outcome::Fine,
Ok(response) => Outcome::Broken(response.status().to_string()),
Err(error) if error.is_timeout() => {
Outcome::Unreachable(format!("no answer in {}", seconds(wait)))
}
Err(error) => {
let mut cause: &dyn std::error::Error = &error;
while let Some(source) = cause.source() {
cause = source;
}
Outcome::Unreachable(cause.to_string())
}
}
})
}
}
fn seconds(wait: Duration) -> String {
let millis = wait.as_millis();
if millis.is_multiple_of(1000) {
format!("{} seconds", millis / 1000)
} else {
format!("{millis} ms")
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::Mutex;
use devpage_core::check::{Preview, Report, document};
use devpage_core::document::Document;
use devpage_core::problem::Problem;
use devpage_core::widgets::{Modules, Widgets};
use super::*;
use crate::changes::{Change, Scope, Subscription};
mod web {
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
pub struct Server {
pub address: String,
pub hits: Arc<Mutex<HashMap<String, usize>>>,
}
pub fn start() -> Server {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
let hits = Arc::new(Mutex::new(HashMap::new()));
let counted = hits.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let counted = counted.clone();
std::thread::spawn(move || {
let mut request = String::new();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let _ = reader.read_line(&mut request);
let path = request.split(' ').nth(1).unwrap_or("/").to_string();
let hit = {
let mut hits = counted.lock().unwrap();
let n = hits.entry(path.clone()).or_insert(0);
*n += 1;
*n
};
let status = if path == "/ok" {
"200 OK"
} else if let Some(ms) = path.strip_prefix("/slow-") {
std::thread::sleep(std::time::Duration::from_millis(
ms.parse().unwrap(),
));
"200 OK"
} else if path == "/drop" {
return;
} else if let Some(n) = path.strip_prefix("/flaky-") {
if hit <= n.parse::<usize>().unwrap() {
return;
}
"200 OK"
} else {
"404 Not Found"
};
let _ = write!(
stream,
"HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
);
});
}
});
Server { address, hits }
}
}
fn widgets_of(document: &Document) -> Widgets {
Widgets::of(document, &Modules::default())
}
fn folder(name: &str, page: &str) -> Document {
let dir = std::env::temp_dir().join(format!("devpage-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("README.md"), page).unwrap();
std::fs::write(
dir.join("devpage.yml"),
"kind: site\ntitle: T\nroot: README.md\n",
)
.unwrap();
Document::load(&dir, &dir).unwrap()
}
#[derive(Default)]
struct Recorder(Mutex<Vec<String>>);
impl Report for Recorder {
fn problem(&self, problem: &Problem) {
self.0.lock().unwrap().push(problem.to_string());
}
}
fn checker(first: u64, retry: u64, retry_after: u64) -> Arc<External> {
External::new(
Arc::new(Http::new()),
Arc::new(Tokio),
waits(first, retry, retry_after),
None,
)
}
fn waits(first: u64, retry: u64, retry_after: u64) -> Waits {
Waits {
first: Duration::from_millis(first),
retry: Duration::from_millis(retry),
retry_after: Duration::from_millis(retry_after),
}
}
fn previewing(waits: Waits) -> (Preview, Subscription) {
let changes = Changes::default();
let heard = changes.subscribe(Scope::root());
let external = External::new(
Arc::new(Http::new()),
Arc::new(Tokio),
waits,
Some(Arc::new(changes)),
);
(Preview::new(external, Arc::new(Recorder::default())), heard)
}
async fn render_until(
preview: &Preview,
doc: &Document,
pages: &[PathBuf],
done: impl Fn(&[Problem]) -> bool,
) -> Vec<Problem> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let found = preview.problems(doc, &widgets_of(doc), pages);
if done(&found) || tokio::time::Instant::now() > deadline {
return found;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test]
async fn check_asks_each_address_once_and_reports_every_link_to_it() {
let web = web::start();
let doc = folder(
"check-external",
&format!(
"# Home\n\n[a]({0}/gone) [b]({0}/gone)\n\n[c]({0}/ok)\n",
web.address
),
);
let found: Vec<String> = document(&doc, Some(&checker(300, 3000, 50)))
.await
.iter()
.map(ToString::to_string)
.collect();
let gone = format!("README.md:3: {}/gone — 404 Not Found", web.address);
assert_eq!(found, vec![gone.clone(), gone], "{found:#?}");
let hits = web.hits.lock().unwrap();
assert_eq!(hits.get("/gone"), Some(&1));
assert_eq!(hits.get("/ok"), Some(&1));
}
#[tokio::test(flavor = "multi_thread")]
async fn the_preview_checks_in_the_background_and_refreshes_open_pages() {
let web = web::start();
let doc = folder(
"preview-external",
&format!("# Home\n\n[a]({0}/missing) [b]({0}/ok)\n", web.address),
);
let (preview, mut heard) = previewing(Waits::default());
let pages = [doc.base.join("README.md")];
assert!(preview.problems(&doc, &widgets_of(&doc), &pages).is_empty());
let change = tokio::time::timeout(Duration::from_secs(5), heard.next())
.await
.expect("a change");
assert_eq!(change, Some(Change::Refresh));
let found = render_until(&preview, &doc, &pages, |found| !found.is_empty()).await;
assert_eq!(found.len(), 1, "{found:#?}");
let missing = format!("{}/missing", web.address);
assert_eq!(found[0].target.as_deref(), Some(missing.as_str()));
assert_eq!(found[0].line, Some(3));
preview.problems(&doc, &widgets_of(&doc), &pages);
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while !web.hits.lock().unwrap().contains_key("/ok")
&& tokio::time::Instant::now() < deadline
{
tokio::time::sleep(Duration::from_millis(10)).await;
}
let hits = web.hits.lock().unwrap().clone();
assert_eq!(hits.get("/missing"), Some(&1));
assert_eq!(hits.get("/ok"), Some(&1));
}
}