Files
takana/crates/hammer-cli/tests/boot_menu.rs
T
sergioandClaude Opus 4.8 a5be781ef6 arranque-grafo: hammer boot menu — orquestador del menú + wiring en el init (ADR 0010)
hammer boot menu ata las tres piezas del contrato: emite el grafo → lanza el
compositor (mirada, --compositor configurable) → activa el nodo que el usuario
dejó en boot-select. Graceful: sin compositor (servidor headless) emite el grafo
y sigue el arranque. Refactor: activate_and_report compartido con boot activate;
--out/--select configurables (testeable sin /run/hammer root-only).

Wiring: iso-image INSTALLER=1 + install-image-efi bundlean el CLI hammer (static
musl) al sistema instalado (el producto trae arje-zero/hammerd pero no el CLI);
el wrapper /sbin/init lo invoca tras hammer-recover, salida al serial (no pinta
tty0 ⇒ respeta cero-parpadeo). Cierra la pieza #3 del handoff mirada (quién
lanza el menú en el boot): lo ownea hammer, desde el hook de init.

Tests: boot_menu.rs (3 caminos del glue: graceful/sin-selección/selección→activate)
+ efi-disk-boot-test asevera que el menú corre. Validado en OVMF: pivote →
INIT-OK → menú (emite boot-graph.json + saltea sin mirada) → arje-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:30:30 -04:00

86 lines
3.7 KiB
Rust

//! 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}"
);
}