use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::time::{Duration, Instant};
use blitz_dom::DocumentConfig;
use blitz_script::{FetchError, ScriptDocument, ScriptFetcher};
use url::Url;
const SERVE_TIMEOUT: Duration = Duration::from_secs(30);
fn config_with_base(base_url: &str) -> DocumentConfig {
DocumentConfig {
base_url: Some(base_url.to_owned()),
..Default::default()
}
}
fn eval_string(doc: &mut ScriptDocument, expression: &str) -> String {
match doc.eval_json(expression) {
Ok(serde_json::Value::String(value)) => value,
other => panic!("expected {expression} to be a string, got {other:?}"),
}
}
fn serve_modules(
routes: Vec<(&'static str, String)>,
) -> (String, std::thread::JoinHandle<Vec<String>>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("loopback is available");
let port = listener
.local_addr()
.expect("the socket has an address")
.port();
listener
.set_nonblocking(true)
.expect("the listener can be polled");
let handle = std::thread::spawn(move || {
let mut requested = Vec::new();
let deadline = Instant::now() + SERVE_TIMEOUT;
while Instant::now() < deadline {
let mut stream = match listener.accept() {
Ok((stream, _)) => stream,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
continue;
}
Err(_) => break,
};
stream
.set_read_timeout(Some(Duration::from_secs(15)))
.expect("the stream can time out");
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
match stream.read(&mut byte) {
Ok(0) | Err(_) => break,
Ok(_) => head.push(byte[0]),
}
}
let head = String::from_utf8_lossy(&head).to_string();
let path = head
.split_whitespace()
.nth(1)
.unwrap_or("/")
.split('?')
.next()
.unwrap_or("/")
.to_owned();
let body = routes
.iter()
.find(|(route, _)| *route == path)
.map(|(_, body)| body.clone());
requested.push(path);
let response = match body {
Some(body) => format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
),
None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_owned(),
};
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
if routes
.iter()
.all(|(route, _)| requested.iter().any(|seen| seen == route))
{
break;
}
}
requested
});
(format!("http://127.0.0.1:{port}"), handle)
}
struct LoopbackFetcher;
impl ScriptFetcher for LoopbackFetcher {
fn fetch(&self, url: &Url) -> Result<String, FetchError> {
if url.scheme() != "http" {
return Err(FetchError::UnsupportedScheme(url.scheme().to_owned()));
}
let host = url.host_str().unwrap_or("127.0.0.1");
let port = url.port().unwrap_or(80);
let path = url.path();
let mut stream = TcpStream::connect((host, port)).map_err(FetchError::Io)?;
stream
.set_read_timeout(Some(Duration::from_secs(30)))
.map_err(FetchError::Io)?;
stream
.write_all(
format!("GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.map_err(FetchError::Io)?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(FetchError::Io)?;
let (head, body) = response
.split_once("\r\n\r\n")
.ok_or_else(|| FetchError::InvalidData("no header terminator".to_owned()))?;
if !head.starts_with("HTTP/1.1 200") {
return Err(FetchError::InvalidData(format!(
"unexpected status for {url}: {}",
head.lines().next().unwrap_or_default()
)));
}
Ok(body.to_owned())
}
}
#[test]
fn an_inline_module_is_parsed_in_module_goal() {
let mut doc = ScriptDocument::from_html(
r#"<script type="module">
const value = await Promise.resolve("module ran");
globalThis.result = value;
</script>"#,
DocumentConfig::default(),
);
doc.execute_scripts();
assert_eq!(eval_string(&mut doc, "globalThis.result"), "module ran");
}
#[test]
fn a_classic_script_still_runs_unchanged() {
let mut doc = ScriptDocument::from_html(
r#"<div id="root"></div>
<script>
const el = document.createElement("span");
el.textContent = "classic";
document.getElementById("root").appendChild(el);
globalThis.classicRan = "yes";
</script>"#,
DocumentConfig::default(),
);
doc.execute_scripts();
assert_eq!(eval_string(&mut doc, "globalThis.classicRan"), "yes");
assert_eq!(
eval_string(&mut doc, "document.getElementById('root').textContent"),
"classic"
);
}
#[test]
fn import_meta_url_is_the_module_url() {
let mut doc = ScriptDocument::from_html(
r#"<script type="module">globalThis.metaUrl = import.meta.url;</script>"#,
config_with_base("https://example.invalid/page/index.html"),
);
doc.execute_scripts();
assert_eq!(
eval_string(&mut doc, "globalThis.metaUrl"),
"https://example.invalid/page/index.html"
);
}
#[test]
fn a_module_imports_a_relative_module_over_the_fetcher() {
let (origin, server) = serve_modules(vec![
(
"/app.js",
r#"import { greeting } from "./dep.js";
globalThis.result = greeting + " from the loader";"#
.to_owned(),
),
("/dep.js", r#"export const greeting = "hello";"#.to_owned()),
]);
let mut doc = ScriptDocument::from_html(
r#"<script type="module" src="/app.js"></script>"#,
config_with_base(&format!("{origin}/index.html")),
)
.with_fetcher(LoopbackFetcher);
doc.execute_scripts();
assert_eq!(
eval_string(&mut doc, "globalThis.result"),
"hello from the loader"
);
let requested = server.join().expect("the server thread finishes");
assert!(
requested.iter().any(|path| path == "/dep.js"),
"the imported module should have been fetched, saw: {requested:?}"
);
}
#[test]
fn a_module_imported_twice_is_one_instance() {
let (origin, server) = serve_modules(vec![
(
"/app.js",
r#"import { bump, count } from "./counter.js";
import "./sibling.js";
bump();
globalThis.result = String(count());"#
.to_owned(),
),
(
"/sibling.js",
r#"import { bump } from "./counter.js";
bump();"#
.to_owned(),
),
(
"/counter.js",
r#"let n = 0;
export function bump() { n += 1; }
export function count() { return n; }"#
.to_owned(),
),
]);
let mut doc = ScriptDocument::from_html(
r#"<script type="module" src="/app.js"></script>"#,
config_with_base(&format!("{origin}/index.html")),
)
.with_fetcher(LoopbackFetcher);
doc.execute_scripts();
assert_eq!(eval_string(&mut doc, "globalThis.result"), "2");
let requested = server.join().expect("the server thread finishes");
let counter_fetches = requested
.iter()
.filter(|path| *path == "/counter.js")
.count();
assert_eq!(
counter_fetches, 1,
"the shared module should be fetched once, saw: {requested:?}"
);
}
#[test]
fn a_nomodule_fallback_is_skipped() {
let mut doc = ScriptDocument::from_html(
r#"<script type="module">globalThis.ran = "module";</script>
<script nomodule>globalThis.ran = "fallback";</script>"#,
DocumentConfig::default(),
);
doc.execute_scripts();
assert_eq!(eval_string(&mut doc, "globalThis.ran"), "module");
}
#[test]
fn an_import_map_resolves_a_bare_specifier() {
let (origin, server) = serve_modules(vec![
(
"/app.js",
r#"import { greeting } from "shared";
globalThis.result = greeting;"#
.to_owned(),
),
(
"/vendor/shared.js",
r#"export const greeting = "mapped";"#.to_owned(),
),
]);
let mut doc = ScriptDocument::from_html(
r#"<script type="importmap">
{"imports": {"shared": "/vendor/shared.js"}}
</script>
<script type="module" src="/app.js"></script>"#,
config_with_base(&format!("{origin}/index.html")),
)
.with_fetcher(LoopbackFetcher);
doc.execute_scripts();
assert_eq!(eval_string(&mut doc, "globalThis.result"), "mapped");
let requested = server.join().expect("the server thread finishes");
assert!(
requested.iter().any(|path| path == "/vendor/shared.js"),
"the mapped URL should have been fetched, saw: {requested:?}"
);
}
#[test]
fn dynamic_import_from_a_classic_script_resolves_against_the_script() {
let (origin, server) = serve_modules(vec![
(
"/assets/entry.js",
r#"globalThis.ready = import("./chunk.js").then((m) => {
globalThis.result = m.value;
});"#
.to_owned(),
),
(
"/assets/chunk.js",
r#"export const value = "dynamic";"#.to_owned(),
),
]);
let mut doc = ScriptDocument::from_html(
r#"<script src="/assets/entry.js"></script>"#,
config_with_base(&format!("{origin}/index.html")),
)
.with_fetcher(LoopbackFetcher);
doc.execute_scripts();
assert_eq!(eval_string(&mut doc, "globalThis.result"), "dynamic");
let requested = server.join().expect("the server thread finishes");
assert!(
requested.iter().any(|path| path == "/assets/chunk.js"),
"the chunk should be resolved beside its script, saw: {requested:?}"
);
}
#[test]
fn an_unmapped_bare_specifier_reports_what_is_wrong() {
let mut doc = ScriptDocument::from_html(
r#"<script type="module">
globalThis.error = "";
try {
await import("preact");
} catch (e) {
globalThis.error = String(e);
}
</script>"#,
config_with_base("https://example.invalid/index.html"),
);
doc.execute_scripts();
let error = eval_string(&mut doc, "globalThis.error");
assert!(
error.contains("bare module specifier") && error.contains("preact"),
"the error should name the specifier and the reason, got: {error}"
);
}
#[test]
fn a_json_module_exports_its_document_as_the_default() {
let (origin, server) = serve_modules(vec![
(
"/app.js",
r#"import config from "./config.json" with { type: "json" };
globalThis.result = config.title;"#
.to_owned(),
),
("/config.json", r#"{"title": "from json"}"#.to_owned()),
]);
let mut doc = ScriptDocument::from_html(
r#"<script type="module" src="/app.js"></script>"#,
config_with_base(&format!("{origin}/index.html")),
)
.with_fetcher(LoopbackFetcher);
doc.execute_scripts();
assert_eq!(eval_string(&mut doc, "globalThis.result"), "from json");
let _ = server.join();
}
#[test]
fn a_module_awaiting_a_timer_settles_on_a_later_poll() {
use blitz_dom::Document as _;
let mut doc = ScriptDocument::from_html(
r#"<script type="module">
await new Promise((resolve) => setTimeout(resolve, 1));
globalThis.result = "settled";
</script>"#,
DocumentConfig::default(),
);
doc.execute_scripts();
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
doc.poll(None);
if matches!(
doc.eval_json("globalThis.result"),
Ok(serde_json::Value::String(_))
) {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(eval_string(&mut doc, "globalThis.result"), "settled");
}
#[test]
fn deferred_scripts_run_after_the_parser_blocking_ones() {
let (origin, server) = serve_modules(vec![
(
"/m.js",
r#"globalThis.order.push("m.js external module");"#.to_owned(),
),
(
"/c.js",
r#"globalThis.order.push("c.js external classic");"#.to_owned(),
),
(
"/d.js",
r#"globalThis.order.push("d.js external classic defer");"#.to_owned(),
),
]);
let mut doc = ScriptDocument::from_html(
r#"<script>globalThis.order = [];</script>
<script type="module">globalThis.order.push("A inline module");</script>
<script>globalThis.order.push("B inline classic");</script>
<script type="module" src="/m.js"></script>
<script src="/c.js"></script>
<script defer src="/d.js"></script>
<script>globalThis.order.push("C inline classic after everything");</script>
<script type="module">globalThis.order.push("D inline module last");</script>"#,
config_with_base(&format!("{origin}/index.html")),
)
.with_fetcher(LoopbackFetcher);
doc.execute_scripts();
assert_eq!(
eval_string(&mut doc, "globalThis.order.join('|')"),
[
"B inline classic",
"c.js external classic",
"C inline classic after everything",
"A inline module",
"m.js external module",
"d.js external classic defer",
"D inline module last",
]
.join("|")
);
let _ = server.join();
}