test(build): close app input collection mutants

Traverse inputs once, preserve deterministic HE/ML-before-stylesheet lowering, and prove nested discovery, explicit surfaces, OUT_DIR fallback, missing inputs, invalid UTF-8, permission failures, output failures, and stable collision ordering through AppBuilder::run.

req: build/003

req: build/004

req: build/009

req: diagnostics/004

req: test/021
This commit is contained in:
slhx agent
2026-07-17 05:03:03 +02:00
parent 2ba0be4d24
commit 4058d05f20
3 changed files with 175 additions and 45 deletions
+169 -44
View File
@@ -202,15 +202,14 @@ impl AppBuilder {
std::fs::create_dir_all(&out_dir)?;
let mut resources = Resources::default();
let input_paths = collect_input_files(&self.template_dir)?;
if self.surfaces.is_empty() {
for path in collect_heml(&self.template_dir)? {
let source = std::fs::read_to_string(&path)?;
let doc = build_ast(Arc::new(source)).map_err(|err| parse_error(&path, err))?;
let Some(doc) = doc else {
continue;
};
let surface = extract_surface(&doc);
resources.add_surface(&self.template_dir, &path, &surface)?;
for path in input_paths.iter().filter(|path| {
path.extension().and_then(|extension| extension.to_str()) == Some("heml")
}) {
let source = std::fs::read_to_string(path)?;
let surface = surface_for_heml_source(path, source)?;
resources.add_surface(&self.template_dir, path, &surface)?;
}
} else {
// req: surface/008
@@ -218,9 +217,14 @@ impl AppBuilder {
resources.add_surface(&self.template_dir, path, surface)?;
}
}
for path in collect_stylesheets(&self.template_dir)? {
let source = std::fs::read_to_string(&path)?;
resources.add_stylesheet(&self.template_dir, &path, &source)?;
for path in input_paths.iter().filter(|path| {
matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("css" | "scss")
)
}) {
let source = std::fs::read_to_string(path)?;
resources.add_stylesheet(&self.template_dir, path, &source)?;
}
write_if_changed(
@@ -1344,49 +1348,22 @@ fn make_resource(
})
}
fn collect_heml(root: &Path) -> io::Result<Vec<PathBuf>> {
fn collect_input_files(root: &Path) -> io::Result<Vec<PathBuf>> {
let mut paths = Vec::new();
if !root.exists() {
return Ok(paths);
}
collect_heml_into(root, &mut paths)?;
collect_input_files_into(root, &mut paths)?;
paths.sort();
Ok(paths)
}
fn collect_heml_into(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
fn collect_input_files_into(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let path = entry?.path();
if path.is_dir() {
collect_heml_into(&path, paths)?;
} else if path.extension().and_then(|ext| ext.to_str()) == Some("heml") {
paths.push(path);
}
}
Ok(())
}
fn collect_stylesheets(root: &Path) -> io::Result<Vec<PathBuf>> {
let mut paths = Vec::new();
if !root.exists() {
return Ok(paths);
}
collect_stylesheets_into(root, &mut paths)?;
paths.sort();
Ok(paths)
}
fn collect_stylesheets_into(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_stylesheets_into(&path, paths)?;
} else if matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("css" | "scss")
) {
collect_input_files_into(&path, paths)?;
} else {
paths.push(path);
}
}
@@ -2282,6 +2259,8 @@ fn parse_error(path: &Path, err: impl std::fmt::Display) -> io::Error {
mod tests {
use super::*;
static OUT_DIR_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn public_heml_inspection_entry_points_preserve_paths_and_io_diagnostics() {
let root = std::env::temp_dir().join(format!(
@@ -2649,6 +2628,152 @@ mod tests {
// test req: build/004 req: client_local/011 req: diagnostics/004
}
#[test]
fn app_builder_discovers_nested_inputs_and_propagates_collection_errors() {
let base = test_dir("hemx-build-collection");
let templates = base.join("templates");
let nested = templates.join("nested");
let out = base.join("out");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(templates.join("a_empty.heml"), "").unwrap();
std::fs::write(
nested.join("panel.heml"),
r#"<section data-hemx-slot="nested_panel"></section>"#,
)
.unwrap();
std::fs::write(nested.join("panel.css"), ".nested-css {}").unwrap();
std::fs::write(nested.join("theme.scss"), ".nested-scss {}").unwrap();
std::fs::write(nested.join("ignored.txt"), [0xff]).unwrap();
app().template_dir(&templates).out_dir(&out).run().unwrap();
let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap();
assert!(syms.contains("slot\tnested/panel.heml::nested_panel\tnested_panel\t"));
assert!(syms.contains("class\tnested/panel.css::nested-css\tnested_css\tnested-css\n"));
assert!(syms.contains("class\tnested/theme.scss::nested-scss\tnested_scss\tnested-scss\n"));
let missing = base.join("missing");
let missing_out = base.join("missing-out");
app()
.template_dir(&missing)
.out_dir(&missing_out)
.run()
.unwrap();
assert_eq!(
std::fs::read_to_string(missing_out.join("hemx.syms")).unwrap(),
"hemx-syms-v1\n"
);
let _out_dir_lock = OUT_DIR_LOCK.lock().unwrap();
let previous_out_dir = std::env::var_os("OUT_DIR");
let env_out = base.join("env-out");
std::env::set_var("OUT_DIR", &env_out);
let env_result = app().template_dir(&missing).run();
match previous_out_dir {
Some(value) => std::env::set_var("OUT_DIR", value),
None => std::env::remove_var("OUT_DIR"),
}
env_result.unwrap();
assert!(env_out.join("hemx.generated.rs").is_file());
let template_file = base.join("not-a-directory");
std::fs::write(&template_file, "not a directory").unwrap();
let error = app()
.template_dir(&template_file)
.out_dir(base.join("file-root-out"))
.run()
.unwrap_err();
assert!(matches!(
error.kind(),
io::ErrorKind::NotADirectory | io::ErrorKind::Other
));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let restricted_root = base.join("restricted-root");
let restricted_nested = restricted_root.join("nested");
std::fs::create_dir_all(&restricted_nested).unwrap();
std::fs::set_permissions(&restricted_nested, std::fs::Permissions::from_mode(0o000))
.unwrap();
let error = app()
.template_dir(&restricted_root)
.out_dir(base.join("restricted-out"))
.run()
.unwrap_err();
std::fs::set_permissions(&restricted_nested, std::fs::Permissions::from_mode(0o700))
.unwrap();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
}
let invalid_heml = base.join("invalid-heml");
std::fs::create_dir_all(&invalid_heml).unwrap();
std::fs::write(invalid_heml.join("bad.heml"), [0xff]).unwrap();
assert_eq!(
app()
.template_dir(&invalid_heml)
.out_dir(base.join("invalid-heml-out"))
.run()
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
let invalid_css = base.join("invalid-css");
std::fs::create_dir_all(&invalid_css).unwrap();
std::fs::write(invalid_css.join("bad.css"), [0xff]).unwrap();
assert_eq!(
app()
.template_dir(&invalid_css)
.out_dir(base.join("invalid-css-out"))
.run()
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
let blocked_out = base.join("blocked-out");
std::fs::write(&blocked_out, "not a directory").unwrap();
assert!(app()
.template_dir(&missing)
.out_dir(&blocked_out)
.run()
.is_err());
let explicit_surface = surface_for_heml_source(
Path::new("explicit.heml"),
r#"<section data-hemx-slot="123"></section>"#.to_owned(),
)
.unwrap();
assert_eq!(
app()
.surface("explicit.heml", explicit_surface)
.out_dir(base.join("explicit-out"))
.run()
.unwrap_err()
.to_string(),
"invalid hemx slot name `123`; expected a Rust identifier"
);
let ordered = base.join("ordered");
std::fs::create_dir_all(&ordered).unwrap();
std::fs::write(ordered.join("a.css"), ".foo-bar {}").unwrap();
std::fs::write(ordered.join("b.css"), ".foo_bar {}").unwrap();
assert_eq!(
app()
.template_dir(&ordered)
.out_dir(base.join("ordered-out"))
.run()
.unwrap_err()
.to_string(),
"duplicate generated class identifier `foo_bar` for CSS classes `foo-bar` and `foo_bar`"
);
let _ = std::fs::remove_dir_all(base);
// test req: build/003 req: build/004 req: build/009 req: diagnostics/004
}
#[test]
fn no_op_build_preserves_generated_artifact_timestamps() {
// req: build/009