//! E2E del orquestador `hammer boot menu` (ADR 0010, glue de arranque): emite el grafo, lanza el //! compositor (mirada) y activa el nodo elegido. Probamos el GLUE headless con stubs de compositor — //! el pivot de generaciones ya lo cubren los tests de `boot_graph::activate`. Tres caminos: //! A) sin compositor instalado (servidor headless) → emite el grafo y sigue el arranque (exit 0). //! B) el compositor no elige nada → "sin selección", sigue el arranque (exit 0). //! C) el compositor escribe un id en boot-select → menu lo consume y se lo pasa a `activate`. use std::path::Path; use std::process::Command; const BIN: &str = env!("CARGO_BIN_EXE_hammer"); fn menu(state: &Path, work: &Path, compositor: &str) -> std::process::Output { Command::new(BIN) .args(["boot", "--state-root"]) .arg(state) .arg("--journal") .arg(work.join("journal")) .arg("menu") .arg("--compositor") .arg(compositor) .arg("--out") .arg(work.join("graph.json")) .arg("--select") .arg(work.join("boot-select")) .output() .expect("ejecutar hammer boot menu") } fn write_stub(path: &Path, body: &str) -> String { std::fs::write(path, body).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); } path.display().to_string() } #[test] fn a_sin_compositor_emite_grafo_y_sigue() { let tmp = tempfile::tempdir().unwrap(); let state = tmp.path().join("state"); std::fs::create_dir_all(&state).unwrap(); let out = menu(&state, tmp.path(), "no-existe-compositor-xyz --arranque"); assert!(out.status.success(), "graceful sin compositor debe exit 0: {out:?}"); // el grafo se publica igual (otros lectores lo usan) assert!(tmp.path().join("graph.json").exists(), "el grafo debe emitirse aun sin compositor"); let so = String::from_utf8_lossy(&out.stdout); assert!(so.contains("sin compositor"), "debe reportar la ausencia del compositor: {so}"); } #[test] fn b_compositor_sin_seleccion_sigue_arranque() { let tmp = tempfile::tempdir().unwrap(); let state = tmp.path().join("state"); std::fs::create_dir_all(&state).unwrap(); let stub = write_stub(&tmp.path().join("noop.sh"), "#!/bin/sh\nexit 0\n"); let out = menu(&state, tmp.path(), &stub); assert!(out.status.success(), "sin selección debe exit 0: {out:?}"); let so = String::from_utf8_lossy(&out.stdout); assert!(so.contains("sin selección"), "debe reportar que no hubo elección: {so}"); } #[test] fn c_seleccion_viaja_de_boot_select_a_activate() { let tmp = tempfile::tempdir().unwrap(); let state = tmp.path().join("state"); std::fs::create_dir_all(&state).unwrap(); // stub que "elige" un id escribiéndolo en el canal de vuelta (el path que menu le pasa por --select). let sel = tmp.path().join("boot-select"); let stub = write_stub( &tmp.path().join("pick.sh"), &format!("#!/bin/sh\necho cafe1234deadbeef > '{}'\n", sel.display()), ); let out = menu(&state, tmp.path(), &stub); // El state_root de prueba está vacío (sin generación viva) ⇒ activate falla; PERO eso prueba que el // id viajó de boot-select → activate (el glue). El pivot exitoso lo cubren los tests de boot_graph. let err = String::from_utf8_lossy(&out.stderr); assert!(!out.status.success(), "con un id inválido y grafo vacío, activate debe fallar"); assert!( err.contains("generación viva") || err.contains("cafe1234") || err.contains("grafo"), "el fallo debe venir de activate (el id llegó): stderr={err}" ); } // --- `hammer boot activate --from-select` idempotente (contrato PLAN-KIKIN §9) --------------------- // arje-zero lo invoca INCONDICIONAL en todo apagado: sin selección debe ser un no-op limpio (exit 0), // y el fichero sólo se consume si la activación tuvo éxito (si falla, queda para diagnóstico). fn activate_from_select(state: &Path, work: &Path, select: &Path) -> std::process::Output { Command::new(BIN) .args(["boot", "--state-root"]) .arg(state) .arg("--journal") .arg(work.join("journal")) .args(["activate", "--from-select", "--select"]) .arg(select) .output() .expect("ejecutar hammer boot activate") } #[test] fn d_activate_from_select_sin_fichero_es_noop() { let tmp = tempfile::tempdir().unwrap(); let state = tmp.path().join("state"); std::fs::create_dir_all(&state).unwrap(); let out = activate_from_select(&state, tmp.path(), &tmp.path().join("boot-select")); assert!(out.status.success(), "sin fichero debe ser no-op exit 0: {out:?}"); let so = String::from_utf8_lossy(&out.stdout); assert!(so.contains("no-op"), "debe reportar el no-op: {so}"); } #[test] fn e_activate_from_select_vacio_es_noop() { let tmp = tempfile::tempdir().unwrap(); let state = tmp.path().join("state"); std::fs::create_dir_all(&state).unwrap(); let sel = tmp.path().join("boot-select"); std::fs::write(&sel, "\n").unwrap(); let out = activate_from_select(&state, tmp.path(), &sel); assert!(out.status.success(), "fichero vacío debe ser no-op exit 0: {out:?}"); let so = String::from_utf8_lossy(&out.stdout); assert!(so.contains("no-op"), "debe reportar el no-op: {so}"); } #[test] fn f_activate_fallido_no_consume_el_select() { let tmp = tempfile::tempdir().unwrap(); let state = tmp.path().join("state"); std::fs::create_dir_all(&state).unwrap(); let sel = tmp.path().join("boot-select"); std::fs::write(&sel, "cafe1234deadbeef\n").unwrap(); // state_root vacío ⇒ no hay generación viva ⇒ activate falla… let out = activate_from_select(&state, tmp.path(), &sel); assert!(!out.status.success(), "con grafo vacío activate debe fallar: {out:?}"); // …y el canal queda intacto para diagnóstico (sólo se consume en éxito). assert!(sel.exists(), "el boot-select NO debe consumirse cuando activate falla"); }