use super::*;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
#[tokio::test]
async fn pipeline_does_not_rebroadcast_its_own_output_echo() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(src.path().join("style.css"), "body { margin: 0; }")
.await
.unwrap();
let broadcaster = Broadcaster::new();
let pipeline = Arc::new(SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
Vec::new(),
out.path().to_path_buf(),
Some((CssTool::TestEcho, CssOptions::new().bundle(true))),
None,
false,
broadcaster.clone(),
));
let mut pipeline_rx = broadcaster.subscribe();
let mut observer = broadcaster.subscribe();
let pipeline_task = tokio::spawn(async move {
while let Some(event) = pipeline_rx.recv().await {
if let Err(e) = pipeline
.process_change(&event.path, &event.change_type)
.await
{
eprintln!("source pipeline error: {e}");
}
}
});
broadcaster.broadcast(ChangeEvent {
path: src.path().join("style.css"),
change_type: ChangeType::Css,
});
let mut count = 0usize;
let window = Duration::from_millis(400);
let deadline = tokio::time::Instant::now() + window;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, observer.recv()).await {
Ok(Some(_)) => count += 1,
Ok(None) | Err(_) => break,
}
}
pipeline_task.abort();
assert_eq!(
count, 2,
"a single source css change must emit exactly the source event + the bundle \
broadcast, and then stop — not feed back forever (got {count})"
);
}
#[tokio::test]
async fn css_bundle_mode_concatenates_into_the_configured_output_name() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(src.path().join("a.css"), "A")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
Vec::new(),
out.path().to_path_buf(),
Some((
CssTool::TestEcho,
CssOptions::new()
.bundle(true)
.bundle_output_name("main.css"),
)),
None,
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert_eq!(fs_read(out.path().join("main.css")), "A");
}
#[tokio::test]
async fn css_per_file_mode_mirrors_every_source_file() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(src.path().join("a.css"), "A")
.await
.unwrap();
tokio::fs::write(src.path().join("b.css"), "B")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
Vec::new(),
out.path().to_path_buf(),
Some((CssTool::TestEcho, CssOptions::new())),
None,
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert_eq!(fs_read(out.path().join("a.css")), "A");
assert_eq!(fs_read(out.path().join("b.css")), "B");
}
#[tokio::test]
async fn js_bundle_mode_writes_the_entry_through_the_tool() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let entry = src.path().join("main.js");
tokio::fs::write(&entry, "const x = 1;").await.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
Vec::new(),
out.path().to_path_buf(),
None,
Some((
JsTool::TestEcho,
JsOptions::new().bundle_entry(&entry, "bundle.js"),
)),
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert_eq!(fs_read(out.path().join("bundle.js")), "const x = 1;");
}
#[tokio::test]
async fn no_tool_configured_processes_nothing() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(src.path().join("app.css"), "body{}")
.await
.unwrap();
tokio::fs::write(src.path().join("app.js"), "x=1;")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
Vec::new(),
out.path().to_path_buf(),
None,
None,
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert!(
!out.path().join("app.css").exists() && !out.path().join("app.js").exists(),
"with no css/js tool configured, nothing should be written to the output dir"
);
}
#[tokio::test]
async fn prune_output_removes_stale_css_bundle_when_no_css_sources_remain() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(out.path().join("styles.css"), "/* stale */")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
Vec::new(),
out.path().to_path_buf(),
Some((CssTool::TestEcho, CssOptions::new().bundle(true))),
None,
true,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert!(
!out.path().join("styles.css").exists(),
"prune is opt-in: with_prune_output should delete the stale bundle produced by no css sources"
);
}
#[tokio::test]
async fn without_prune_stale_css_bundle_is_left_in_place() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(out.path().join("styles.css"), "/* stale */")
.await
.unwrap();
let pipeline = SourcePipeline::new(
vec![src.path().to_path_buf()],
Vec::new(),
Vec::new(),
out.path().to_path_buf(),
Some((CssTool::TestEcho, CssOptions::new().bundle(true))),
None,
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert!(
out.path().join("styles.css").exists(),
"without with_prune_output the stale bundle must be left in place"
);
}
#[tokio::test]
async fn asset_folder_mirrors_every_file_byte_identical_on_full_build() {
let assets = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
tokio::fs::write(assets.path().join("index.html"), "<html></html>")
.await
.unwrap();
tokio::fs::create_dir(assets.path().join("images"))
.await
.unwrap();
tokio::fs::write(assets.path().join("images/logo.svg"), "<svg></svg>")
.await
.unwrap();
let pipeline = SourcePipeline::new(
Vec::new(),
Vec::new(),
vec![assets.path().to_path_buf()],
out.path().to_path_buf(),
None,
None,
false,
Broadcaster::new(),
);
pipeline.full_build().await.unwrap();
assert_eq!(fs_read(out.path().join("index.html")), "<html></html>");
assert_eq!(
fs_read(out.path().join("images/logo.svg")),
"<svg></svg>",
"nested paths under the asset folder must be preserved in the output"
);
}
#[tokio::test]
async fn asset_folder_change_recopies_just_that_file_and_broadcasts() {
let assets = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
let source = assets.path().join("index.html");
tokio::fs::write(&source, "v1").await.unwrap();
let broadcaster = Broadcaster::new();
let mut observer = broadcaster.subscribe();
let pipeline = SourcePipeline::new(
Vec::new(),
Vec::new(),
vec![assets.path().to_path_buf()],
out.path().to_path_buf(),
None,
None,
false,
broadcaster,
);
tokio::fs::write(&source, "v2").await.unwrap();
pipeline
.process_change(&source, &ChangeType::Html)
.await
.unwrap();
assert_eq!(fs_read(out.path().join("index.html")), "v2");
let event = observer.recv().await.expect("must broadcast the rebuild");
assert_eq!(event.path, out.path().join("index.html"));
}
fn fs_read(path: PathBuf) -> String {
std::fs::read_to_string(path).unwrap()
}