max / makenotwork
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. |