use std::time::Duration;
use crate::{App, INDEX_HTML};
fn is_reload(frame: &str) -> bool {
serde_json::from_str::<serde_json::Value>(frame)
.ok()
.and_then(|v| v["type"].as_str().map(str::to_owned))
.is_some_and(|t| t == "full-reload" || t == "update")
}
async fn next_reload(
rx: &mut tokio::sync::broadcast::Receiver<String>,
within: Duration,
) -> Option<String> {
tokio::time::timeout(within, async {
loop {
match rx.recv().await {
Ok(frame) if is_reload(&frame) => return frame,
Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
Err(err) => panic!("reload channel closed: {err}"),
}
}
})
.await
.ok()
}
#[tokio::test(flavor = "multi_thread")]
async fn ready_watcher_sees_one_edit_and_a_closed_one_sees_none() {
let app = App::new(&[
("package.json", r#"{"name":"watch-app","version":"1.0.0"}"#),
("src/main.js", "document.title = \"v1\";\n"),
("index.html", INDEX_HTML),
]);
let server = app.serve().await;
server.text("/src/main.js").await;
let mut rx = server.built.reload_tx.subscribe();
app.write(&[("src/main.js", "document.title = \"v2\";\n")]);
assert!(
next_reload(&mut rx, Duration::from_secs(10))
.await
.is_some(),
"an edit after watcher_ready reaches the browser channel"
);
server.built.close_watcher();
app.write(&[("src/main.js", "document.title = \"v3\";\n")]);
let late = next_reload(&mut rx, Duration::from_secs(2)).await;
assert!(late.is_none(), "closed watcher still delivered {late:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn an_edit_right_after_the_listener_binds_is_seen() {
let app = App::new(&[
("package.json", r#"{"name":"bind-app","version":"1.0.0"}"#),
("src/main.js", "document.title = \"v1\";\n"),
("index.html", INDEX_HTML),
]);
let mut dev = app.dev();
dev.port = Some(0);
let built = dev.build_app().await.expect("dev server builds");
let feed = built.watch_feed.subscribe();
let (_listener, _port) = built.bind_listener().await.expect("listener binds");
app.write(&[("src/late.js", "export {};\n")]);
let late = app.root.join("src/late.js");
let seen = tokio::task::spawn_blocking(move || {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while let Some(left) = deadline.checked_duration_since(std::time::Instant::now()) {
match feed.recv_timeout(left) {
Ok(ev) if ev.paths.iter().any(|p| p.ends_with("src/late.js")) => return true,
Ok(_) => {}
Err(_) => return false,
}
}
false
})
.await
.unwrap();
built.close_watcher();
assert!(
seen,
"the write after bind never reached the watcher ({})",
late.display()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_hung_hot_update_hook_never_holds_a_restart_back() {
let app = App::new(&[
("package.json", r#"{"name":"hung-hook","version":"1.0.0"}"#),
(
"oj.plugins.mjs",
"export default [{ name: \"hang\", handleHotUpdate(ctx) {\n if (ctx.file.endsWith(\"/src/main.js\")) return new Promise(() => {});\n} }];\n",
),
("src/main.js", "document.title = \"v1\";\n"),
("index.html", INDEX_HTML),
]);
let server = app.serve().await;
server.text("/src/main.js").await;
app.write(&[("src/main.js", "document.title = \"v2\";\n")]);
tokio::time::sleep(Duration::from_millis(500)).await;
app.write(&[(".env", "VITE_X=1\n")]);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut restarts = Vec::new();
while restarts.is_empty() && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(50)).await;
restarts = crate::take_restarts_for(&app.root);
}
assert!(
restarts.len() == 1 && restarts[0].ends_with("/.env changed"),
"the .env edit never restarted: {restarts:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_hung_hot_update_hook_never_holds_another_files_update() {
let app = App::new(&[
("package.json", r#"{"name":"hung-other","version":"1.0.0"}"#),
(
"oj.plugins.mjs",
"export default [{ name: \"hang\", handleHotUpdate(ctx) {\n if (ctx.file.endsWith(\"/src/a.js\")) return new Promise(() => {});\n} }];\n",
),
("src/main.js", "import \"./a.js\";\nimport \"./b.js\";\n"),
("src/a.js", "export const a = 1;\n"),
("src/b.js", "export const b = 1;\n"),
("index.html", INDEX_HTML),
]);
let server = app.serve().await;
for url in ["/src/main.js", "/src/a.js", "/src/b.js"] {
server.text(url).await;
}
let mut rx = server.built.reload_tx.subscribe();
app.write(&[("src/a.js", "export const a = 2;\n")]);
tokio::time::sleep(Duration::from_millis(500)).await;
app.write(&[("src/b.js", "export const b = 2;\n")]);
let frame = next_reload(&mut rx, Duration::from_secs(10)).await;
assert!(
frame.as_deref().is_some_and(|f| f.contains("/src/b.js")),
"b.js's update never arrived behind a.js's hung hook: {frame:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_restart_belongs_to_its_own_apps_server() {
let files = [
(
"package.json",
r#"{"name":"own-restart","version":"1.0.0"}"#,
),
("src/main.js", "document.title = \"v1\";\n"),
("index.html", INDEX_HTML),
];
let (ours, other) = (App::new(&files), App::new(&files));
let _server = ours.serve().await;
let bystander = other.serve().await;
ours.write(&[(".env", "VITE_X=1\n")]);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(50)).await;
let restarted = crate::RESTARTS
.lock()
.unwrap()
.iter()
.any(|r| r.ends_with("/.env changed"));
if restarted {
break;
}
}
drop(bystander);
let restarts = crate::take_restarts_for(&ours.root);
assert!(
restarts.len() == 1 && restarts[0].ends_with("/.env changed"),
"the restart was not left for its own app: {restarts:?}"
);
}