From dcbbe6c716a56747c79723fc6efa1c45bdf49c1e Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:11:50 -0400 Subject: [PATCH] test(integration): add bootstrap flow tests via notstrap::run() Three tests covering the full bootstrap path: dot-hook execution with symlink verification, setup-hook skip-on-rerun via state file, and fail-fast behaviour when the age key is missing. Co-Authored-By: Claude Sonnet 4.6 --- tests/integration/Cargo.toml | 4 +- tests/integration/tests/bootstrap.rs | 240 +++++++++++++++++++++++++ tests/integration/tests/placeholder.rs | 0 3 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 tests/integration/tests/bootstrap.rs delete mode 100644 tests/integration/tests/placeholder.rs diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 3035f6e..172aea2 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -6,8 +6,8 @@ license.workspace = true publish = false [[test]] -name = "placeholder" -path = "tests/placeholder.rs" +name = "bootstrap" +path = "tests/bootstrap.rs" [dev-dependencies] notstrap = { path = "../../crates/notstrap" } diff --git a/tests/integration/tests/bootstrap.rs b/tests/integration/tests/bootstrap.rs new file mode 100644 index 0000000..461fb1f --- /dev/null +++ b/tests/integration/tests/bootstrap.rs @@ -0,0 +1,240 @@ +use notcore::{HookPhase, HookSpec, StepStatus}; +use nothooks::{run_phase, HookRunner}; +use notstrap::{run, BootstrapOptions}; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +struct TestEnv { + dotfiles: TempDir, + home: TempDir, + config: PathBuf, + key_file: PathBuf, +} + +fn make_test_env() -> TestEnv { + let dotfiles = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + let d = dotfiles.path(); + + // age key file (content doesn't matter — FileSource reads it verbatim) + let key_file = d.join("age.key"); + fs::write(&key_file, "AGE-SECRET-KEY-1TESTKEY\n").unwrap(); + + // notfiles.toml — one package "shell" targeting home tempdir + fs::write( + d.join("notfiles.toml"), + format!( + "[defaults]\nmethod = \"symlink\"\ntarget = \"{}\"\n", + home.path().display() + ), + ) + .unwrap(); + + // a file to link inside a package dir + let pkg = d.join("shell"); + fs::create_dir_all(&pkg).unwrap(); + fs::write(pkg.join(".zshrc"), "# test zshrc\n").unwrap(); + + // scripts dir for hooks + fs::create_dir_all(d.join("scripts")).unwrap(); + + // notstrap.toml — dotfiles_dir points to existing tempdir so clone is skipped + let config = d.join("notstrap.toml"); + fs::write( + &config, + format!( + "[bootstrap]\ndotfiles_repo = \"https://example.com/fake.git\"\ndotfiles_dir = \"{}\"\n", + d.display() + ), + ) + .unwrap(); + + TestEnv { + dotfiles, + home, + config, + key_file, + } +} + +/// Build a BootstrapOptions pointing at env, with optional force flag. +fn make_opts(env: &TestEnv, force: bool) -> BootstrapOptions { + BootstrapOptions { + config: env.config.clone(), + force, + key_file: Some(env.key_file.clone()), + dotfiles: Some(env.dotfiles.path().to_path_buf()), + check_prereqs: None, + env_injector: None, + } +} + +// ── Test 1 ──────────────────────────────────────────────────────────────────── + +/// Full bootstrap with a dot hook succeeds and symlinks .zshrc into home. +#[test] +fn test_full_bootstrap_dot_hooks_only() { + let env = make_test_env(); + let d = env.dotfiles.path(); + + // Add a dot hook script + let script = d.join("scripts/greet.nu"); + fs::write(&script, "print hello\n").unwrap(); + + // Rewrite notstrap.toml to include the dot hook + fs::write( + &env.config, + format!( + "[bootstrap]\n\ + dotfiles_repo = \"https://example.com/fake.git\"\n\ + dotfiles_dir = \"{dotfiles}\"\n\n\ + [[hooks]]\n\ + name = \"greet\"\n\ + script = \"{script}\"\n\ + phase = \"dot\"\n", + dotfiles = d.display(), + script = script.display(), + ), + ) + .unwrap(); + + let report = run(make_opts(&env, false)).unwrap(); + assert!( + !report.has_failures(), + "unexpected failures: {:?}", + report + .steps + .iter() + .filter(|s| matches!(s.status, StepStatus::Failed(_))) + .collect::>() + ); + + // .zshrc should be symlinked into home + let link_target = env.home.path().join(".zshrc"); + assert!(link_target.exists(), ".zshrc should exist in home"); + assert!( + link_target.is_symlink(), + ".zshrc should be a symlink (not a copy)" + ); +} + +// ── Test 2 ──────────────────────────────────────────────────────────────────── + +/// Setup hooks run on first bootstrap then are skipped on subsequent runs. +/// +/// notstrap::run() only exposes per-phase summaries in the top-level Report, +/// so we verify skip behaviour by inspecting .nothooks-state.toml after the +/// first run and by calling nothooks::run_phase directly after the second run. +#[test] +fn test_setup_hooks_skipped_on_rerun() { + let env = make_test_env(); + let d = env.dotfiles.path(); + + let script = d.join("scripts/setup.nu"); + fs::write(&script, "print setup_ran\n").unwrap(); + + let config_content = format!( + "[bootstrap]\n\ + dotfiles_repo = \"https://example.com/fake.git\"\n\ + dotfiles_dir = \"{dotfiles}\"\n\n\ + [[hooks]]\n\ + name = \"install-tools\"\n\ + script = \"{script}\"\n\ + phase = \"setup\"\n", + dotfiles = d.display(), + script = script.display(), + ); + fs::write(&env.config, &config_content).unwrap(); + + // ── First run ────────────────────────────────────────────────────────── + let r1 = run(make_opts(&env, false)).unwrap(); + assert!( + !r1.has_failures(), + "first run should not fail: {:?}", + r1.steps + ); + + // State file must exist and record the hook as done + let state_file = d.join(".nothooks-state.toml"); + assert!( + state_file.exists(), + ".nothooks-state.toml must be written after first run" + ); + let state_content = fs::read_to_string(&state_file).unwrap(); + assert!( + state_content.contains("install-tools"), + "state file should record install-tools as done, got: {state_content}" + ); + + // ── Second run ───────────────────────────────────────────────────────── + let r2 = run(make_opts(&env, false)).unwrap(); + assert!( + !r2.has_failures(), + "second run should not fail: {:?}", + r2.steps + ); + + // Verify skip behaviour directly via nothooks — HookRunner should skip + // the setup hook because state file already marks it done. + let hook_spec = HookSpec { + name: "install-tools".to_string(), + script: script.to_str().unwrap().to_string(), + phase: HookPhase::Setup, + }; + let runner = HookRunner::new(d.to_path_buf()); + let phase_report = run_phase(&[hook_spec], &HookPhase::Setup, &runner); + let step = phase_report + .steps + .iter() + .find(|s| s.name == "install-tools") + .expect("install-tools step should be in phase report"); + assert_eq!( + step.status, + StepStatus::Skipped, + "install-tools should be skipped on second run" + ); +} + +// ── Test 3 ──────────────────────────────────────────────────────────────────── + +/// A nonexistent key file causes the age-key step to fail and stops early +/// (no link-dotfiles step is recorded). +#[test] +fn test_bootstrap_fails_fast_on_bad_key() { + let env = make_test_env(); + let d = env.dotfiles.path(); + + let opts = BootstrapOptions { + config: env.config.clone(), + force: false, + key_file: Some(PathBuf::from("/nonexistent/no-such-key.age")), + dotfiles: Some(d.to_path_buf()), + check_prereqs: None, + env_injector: None, + }; + + let report = run(opts).unwrap(); + + // age key step must fail + let key_step = report + .steps + .iter() + .find(|s| s.name == "age key") + .expect("age key step should be present"); + assert!( + matches!(key_step.status, StepStatus::Failed(_)), + "bad key path should fail age key step, got: {:?}", + key_step.status + ); + + // no link step should appear (bootstrap stops early after key failure) + let has_link = report + .steps + .iter() + .any(|s| s.name.starts_with("link dotfiles")); + assert!( + !has_link, + "should not reach link dotfiles step after key failure" + ); +} diff --git a/tests/integration/tests/placeholder.rs b/tests/integration/tests/placeholder.rs deleted file mode 100644 index e69de29..0000000