Skip to main content

max / makenotwork

bento: cover the missing-recipe target-failure path Add a_target_with_no_recipe_file_fails_at_checkout: a target whose recipe file is absent must fail at the checkout boundary with a readable "reading recipe" error, before any step runs. Exercises read_recipe's error branch and the fail_target path in run_target that neither the happy-path nor the failing-preflight test reaches, and asserts finalize_build still reaps the slot on this early-failure path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 00:20 UTC
Commit: 0b594dcd1d0d9d4314ec328a42febdb99ff722c8
Parent: 8fe722a
1 file changed, +100 insertions, -0 deletions
@@ -1246,6 +1246,106 @@ repo = "{}"
1246 1246 );
1247 1247 }
1248 1248
1249 + /// A target whose recipe file is absent must fail at the `checkout` boundary
1250 + /// with a readable "reading recipe" error, before any step runs — exercising
1251 + /// `read_recipe`'s error branch and the `fail_target` path in `run_target`
1252 + /// that neither the happy-path nor the failing-preflight test reaches. The
1253 + /// app is configured to ship linux, but no `linux.rhai` is written.
1254 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1255 + async fn a_target_with_no_recipe_file_fails_at_checkout() {
1256 + let tmp = tempfile::tempdir().unwrap();
1257 + let root = tmp.path();
1258 +
1259 + let repo = root.join("app");
1260 + std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
1261 + std::fs::write(
1262 + repo.join("src-tauri/tauri.conf.json"),
1263 + r#"{"version":"0.0.1"}"#,
1264 + )
1265 + .unwrap();
1266 + // The recipe dir exists but is empty — no linux.rhai.
1267 + std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
1268 +
1269 + let cfg = Config::for_tests(root);
1270 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
1271 + std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap();
1272 + let topo = Topology::from_str_for_tests(&format!(
1273 + r#"
1274 + [[host]]
1275 + name = "fw13"
1276 + ssh = "local"
1277 + targets = ["linux/x86_64"]
1278 +
1279 + [app.demo]
1280 + repo = "{}"
1281 + "#,
1282 + repo.display()
1283 + ))
1284 + .unwrap();
1285 + let state = test_state(pool.clone(), topo, cfg);
1286 +
1287 + let build_id = start_build(
1288 + state.clone(),
1289 + AppId::new("demo"),
1290 + Version::parse("0.0.1").unwrap(),
1291 + vec!["linux/x86_64".parse().unwrap()],
1292 + )
1293 + .await
1294 + .unwrap();
1295 +
1296 + // Poll the build row: it is terminal only once finalize_build joins the
1297 + // one (failing) target task.
1298 + let mut build_status = String::new();
1299 + for _ in 0..100 {
1300 + build_status = sqlx::query_scalar("SELECT status FROM builds WHERE id = ?")
1301 + .bind(build_id)
1302 + .fetch_one(&pool)
1303 + .await
1304 + .unwrap();
1305 + if build_status != "running" {
1306 + break;
1307 + }
1308 + tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1309 + }
1310 +
1311 + // The target failed, and the error points at the unreadable recipe.
1312 + let (status, error): (String, Option<String>) =
1313 + sqlx::query_as("SELECT status, error FROM target_runs WHERE build_id = ?")
1314 + .bind(build_id)
1315 + .fetch_one(&pool)
1316 + .await
1317 + .unwrap();
1318 + assert_eq!(status, "failed", "a missing recipe must fail the target");
1319 + assert!(
1320 + error.is_some_and(|e| e.contains("reading recipe")),
1321 + "the failure must name the recipe it could not read",
1322 + );
1323 +
1324 + // The recipe never ran, so no step_runs row was ever created — the
1325 + // failure is at the checkout boundary, ahead of any step.
1326 + let step_count: i64 = sqlx::query_scalar(
1327 + "SELECT COUNT(*) FROM step_runs WHERE target_run_id IN \
1328 + (SELECT id FROM target_runs WHERE build_id = ?)",
1329 + )
1330 + .bind(build_id)
1331 + .fetch_one(&pool)
1332 + .await
1333 + .unwrap();
1334 + assert_eq!(
1335 + step_count, 0,
1336 + "no step should run when the recipe is absent"
1337 + );
1338 +
1339 + assert_eq!(
1340 + build_status, "failed",
1341 + "the build rolls up the target failure",
1342 + );
1343 + assert!(
1344 + state.active.lock().await.is_empty(),
1345 + "finalize_build reaps the slot even on the recipe-read failure path",
1346 + );
1347 + }
1348 +
1249 1349 /// Run 2 S5: a publish whose version is not strictly newer than the latest
1250 1350 /// already published for the same (app, target, channel) is refused — an
1251 1351 /// older build cannot republish over a live newer release.