From 7ca2b3c26d5e41b301cb87fedd68a42006fdfe7a Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:47:21 -0400 Subject: [PATCH] docs: add integration tests implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-04-01-integration-tests.md | 702 ++++++++++++++++++ 1 file changed, 702 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-01-integration-tests.md diff --git a/docs/superpowers/plans/2026-04-01-integration-tests.md b/docs/superpowers/plans/2026-04-01-integration-tests.md new file mode 100644 index 0000000..56bd3dd --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-integration-tests.md @@ -0,0 +1,702 @@ +# Integration Tests Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract `notstrap` orchestration into a testable library, then add a workspace-level integration test crate exercising the full cross-crate bootstrap flow. + +**Architecture:** `notstrap::run(BootstrapOptions)` becomes the public entry point; `main()` becomes a thin shim. A new `tests/integration/` workspace crate imports all feature crates and runs 5 tests via `cargo nextest`. + +**Tech Stack:** Rust edition 2024, `tempfile`, `assert_fs`, `cargo-nextest` + +--- + +## File Map + +| Action | Path | Responsibility | +|--------|------|----------------| +| Modify | `crates/notstrap/src/lib.rs` | `BootstrapOptions`, `run()`, config types | +| Modify | `crates/notstrap/src/main.rs` | Thin CLI shim only | +| Modify | `crates/notstrap/Cargo.toml` | Add `[lib]` section | +| Create | `tests/integration/Cargo.toml` | Test-only crate manifest | +| Create | `tests/integration/tests/bootstrap.rs` | 3 full-flow tests via `notstrap::run()` | +| Create | `tests/integration/tests/cross_crate.rs` | 2 cross-crate boundary tests | +| Modify | `Cargo.toml` (workspace root) | Add `tests/integration` to `members` | + +--- + +## Task 1: Extract notstrap orchestration into lib.rs + +**Files:** +- Modify: `crates/notstrap/src/lib.rs` +- Modify: `crates/notstrap/src/main.rs` +- Modify: `crates/notstrap/Cargo.toml` + +- [ ] **Step 1: Add `[lib]` section to notstrap Cargo.toml** + +In `crates/notstrap/Cargo.toml`, add after the existing `[[bin]]` block: + +```toml +[lib] +name = "notstrap" +path = "src/lib.rs" +``` + +- [ ] **Step 2: Write lib.rs with BootstrapOptions and run()** + +Replace `crates/notstrap/src/lib.rs` (currently just `// placeholder`) with: + +```rust +use anyhow::{Context, Result}; +use notcore::{HookPhase, Report, StepStatus}; +use notfiles::{link, LinkOptions}; +use nothooks::{run_phase, HookRunner}; +use notsecrets::{install_age_key, resolve_age_key, FileSource, BitwardenSource, PromptSource}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +pub mod prereqs; +pub mod repo; + +#[derive(Deserialize)] +pub struct NotstrapConfig { + pub bootstrap: BootstrapSection, + #[serde(default)] + pub hooks: Vec, +} + +#[derive(Deserialize)] +pub struct BootstrapSection { + pub dotfiles_repo: String, + pub dotfiles_dir: String, + #[serde(default = "default_bw_item")] + pub bw_age_item: String, + #[serde(default = "default_sops_file")] + pub sops_file: String, +} + +pub fn default_bw_item() -> String { "age-key-dotfiles".to_string() } +pub fn default_sops_file() -> String { "secrets/bootstrap.sops.env".to_string() } + +pub struct BootstrapOptions { + pub config: PathBuf, + pub force: bool, + pub key_file: Option, + pub dotfiles: Option, + /// None = skip prereq check (tests). Some(f) = run f(). + pub check_prereqs: Option Result<()>>>, + /// None = skip env injection (tests). Some(f) = decrypt sops at path and inject. + pub env_injector: Option Result>>, +} + +pub fn run(opts: BootstrapOptions) -> Result { + let mut report = Report::default(); + + // 1. Prerequisites + if let Some(check) = opts.check_prereqs { + match check() { + Ok(_) => { report.add("prerequisites", StepStatus::Ok); } + Err(e) => { + report.add("prerequisites", StepStatus::Failed(e.to_string())); + return Ok(report); + } + } + } + + // 2. Load config + let config_content = std::fs::read_to_string(&opts.config) + .with_context(|| format!("cannot read {}", opts.config.display()))?; + let cfg: NotstrapConfig = toml::from_str(&config_content)?; + + let dotfiles_dir = opts.dotfiles.unwrap_or_else(|| { + notcore::expand_tilde(&cfg.bootstrap.dotfiles_dir).unwrap() + }); + + // 3. Clone dotfiles if missing + match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) { + Ok(true) => { report.add("clone dotfiles", StepStatus::Ok); } + Ok(false) => { report.add("clone dotfiles", StepStatus::Skipped); } + Err(e) => { + report.add("clone dotfiles", StepStatus::Failed(e.to_string())); + return Ok(report); + } + } + + // 4. Retrieve age key and install + let sources: Vec> = if let Some(kf) = opts.key_file { + vec![Box::new(FileSource::new(kf))] + } else { + vec![ + Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)), + Box::new(PromptSource), + ] + }; + + match resolve_age_key(sources) { + Ok(key) => { + install_age_key(&key)?; + report.add("age key", StepStatus::Ok); + } + Err(e) => { + report.add("age key", StepStatus::Failed(e.to_string())); + return Ok(report); + } + } + + // 5. Decrypt sops secrets (optional) + if let Some(injector) = opts.env_injector { + let sops_path = dotfiles_dir.join(&cfg.bootstrap.sops_file); + match injector(&sops_path) { + Ok(env_content) => { + for line in env_content.lines() { + if let Some((k, v)) = line.split_once('=') { + let k = k.trim(); + let v = v.trim().trim_matches('"'); + if !k.is_empty() && !k.starts_with('#') { + // Safety: single-threaded bootstrap, no concurrent env readers + unsafe { std::env::set_var(k, v); } + } + } + } + report.add("decrypt secrets", StepStatus::Ok); + } + Err(e) => { + report.add("decrypt secrets", StepStatus::Failed(e.to_string())); + return Ok(report); + } + } + } + + // 6. Link dotfiles + let link_opts = LinkOptions { force: opts.force, no_backup: false, dry_run: false, verbose: false }; + match link(&dotfiles_dir, &[], &link_opts) { + Ok(state) => { + let count = state.entries.len(); + report.add(format!("link dotfiles ({count} files)"), StepStatus::Ok); + } + Err(e) => { + report.add("link dotfiles", StepStatus::Failed(e.to_string())); + } + } + + // 7. Run hooks + let runner = if opts.force { + HookRunner::with_force(dotfiles_dir.clone()) + } else { + HookRunner::new(dotfiles_dir.clone()) + }; + + for (phase, label) in [(HookPhase::Dot, "dot hooks"), (HookPhase::Setup, "setup hooks")] { + let phase_report = run_phase(&cfg.hooks, &phase, &runner); + let failed = phase_report.steps.iter() + .filter(|s| matches!(s.status, notcore::StepStatus::Failed(_))) + .count(); + let summary = if failed > 0 { + StepStatus::Failed(format!("{failed} failed")) + } else { + StepStatus::Ok + }; + report.add(label, summary); + } + + Ok(report) +} +``` + +- [ ] **Step 3: Slim down main.rs to a thin shim** + +Replace the full contents of `crates/notstrap/src/main.rs` with: + +```rust +use anyhow::Result; +use clap::Parser; +use notstrap::{BootstrapOptions, prereqs, run}; + +#[derive(Parser)] +#[command(name = "notstrap", about = "Bootstrap a new machine from dotfiles")] +struct Cli { + #[command(subcommand)] + command: Cmd, +} + +#[derive(clap::Subcommand)] +enum Cmd { + Run { + #[arg(long, default_value = "notstrap.toml")] + config: std::path::PathBuf, + #[arg(long)] + force: bool, + #[arg(long)] + key_file: Option, + #[arg(long)] + dotfiles: Option, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let Cmd::Run { config, force, key_file, dotfiles } = cli.command; + let opts = BootstrapOptions { + config, + force, + key_file, + dotfiles, + check_prereqs: Some(Box::new(prereqs::check_prerequisites)), + env_injector: Some(Box::new(|p| notsecrets::decrypt_sops(p))), + }; + let report = run(opts)?; + report.print(); + if report.has_failures() { + std::process::exit(1); + } + Ok(()) +} +``` + +- [ ] **Step 4: Verify it compiles** + +```bash +cargo check -p notstrap +``` + +Expected: no errors. + +- [ ] **Step 5: Run existing tests** + +```bash +cargo nextest run --workspace +``` + +Expected: all tests pass (same count as before). + +- [ ] **Step 6: Commit** + +```bash +git add crates/notstrap/ +git commit -m "refactor(notstrap): extract run() into lib with BootstrapOptions" +``` + +--- + +## Task 2: Create the integration test crate + +**Files:** +- Create: `tests/integration/Cargo.toml` +- Modify: `Cargo.toml` (workspace root) + +- [ ] **Step 1: Create tests/integration/Cargo.toml** + +```toml +[package] +name = "integration" +version = "0.1.0" +edition = "2024" +license.workspace = true +publish = false + +[dev-dependencies] +notstrap = { path = "../../crates/notstrap" } +notfiles = { path = "../../crates/notfiles" } +nothooks = { path = "../../crates/nothooks" } +notsecrets = { path = "../../crates/notsecrets" } +notcore = { path = "../../crates/notcore" } +tempfile = { workspace = true } +assert_fs = { workspace = true } +anyhow = { workspace = true } +``` + +- [ ] **Step 2: Add to workspace members** + +In root `Cargo.toml`, add `"tests/integration"` to the `members` array: + +```toml +[workspace] +members = [ + "crates/notcore", + "crates/notfiles", + "crates/notsecrets", + "crates/nothooks", + "crates/notstrap", + "tests/integration", +] +``` + +- [ ] **Step 3: Verify the crate is recognized** + +```bash +cargo check -p integration +``` + +Expected: no errors (empty crate compiles fine). + +- [ ] **Step 4: Commit** + +```bash +git add tests/ Cargo.toml Cargo.lock +git commit -m "chore: add integration test crate to workspace" +``` + +--- + +## Task 3: Write bootstrap.rs tests (full flow) + +**Files:** +- Create: `tests/integration/tests/bootstrap.rs` + +These tests exercise `notstrap::run()` against a real temp directory. They require `nu` to be installed (hook scripts are real `.nu` files). They skip prereqs and sops. + +Helper used by all three tests — define at the top of the file: + +```rust +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; +use notstrap::{BootstrapOptions, run}; +use notcore::StepStatus; + +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 + let key_file = d.join("age.key"); + fs::write(&key_file, "AGE-SECRET-KEY-1TESTKEY\n").unwrap(); + + // notfiles.toml — one package "dotfiles" 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 — fake repo (won't clone since dir already exists) + 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 } +} +``` + +- [ ] **Step 1: Write test_full_bootstrap_dot_hooks_only** + +```rust +#[test] +fn test_full_bootstrap_dot_hooks_only() { + let env = make_test_env(); + let d = env.dotfiles.path(); + + // Add a dot hook + let script = d.join("scripts/greet.nu"); + fs::write(&script, "print hello\n").unwrap(); + + // Append hook to notstrap.toml + fs::write(&env.config, format!( + "[bootstrap]\ndotfiles_repo = \"https://example.com/fake.git\"\ndotfiles_dir = \"{dotfiles}\"\n\n\ + [[hooks]]\nname = \"greet\"\nscript = \"{script}\"\nphase = \"dot\"\n", + dotfiles = d.display(), + script = script.display(), + )).unwrap(); + + let opts = BootstrapOptions { + config: env.config.clone(), + force: false, + key_file: Some(env.key_file.clone()), + dotfiles: Some(d.to_path_buf()), + check_prereqs: None, + env_injector: None, + }; + + let report = run(opts).unwrap(); + assert!(!report.has_failures(), "unexpected failures: {report:?}"); + + // dot hook ran + let greet_step = report.steps.iter().find(|s| s.name == "greet"); + assert!(greet_step.is_some()); + assert_eq!(greet_step.unwrap().status, StepStatus::Ok); + + // .zshrc was linked + let link_target = env.home.path().join(".zshrc"); + assert!(link_target.exists(), ".zshrc should be linked into home"); + assert!(link_target.is_symlink(), ".zshrc should be a symlink"); +} +``` + +- [ ] **Step 2: Run test to verify it passes** + +```bash +cargo nextest run -p integration --test bootstrap test_full_bootstrap_dot_hooks_only +``` + +Expected: PASS + +- [ ] **Step 3: Write test_setup_hooks_skipped_on_rerun** + +```rust +#[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]\ndotfiles_repo = \"https://example.com/fake.git\"\ndotfiles_dir = \"{dotfiles}\"\n\n\ + [[hooks]]\nname = \"install-tools\"\nscript = \"{script}\"\nphase = \"setup\"\n", + dotfiles = d.display(), + script = script.display(), + ); + fs::write(&env.config, &config_content).unwrap(); + + let make_opts = |force: bool| BootstrapOptions { + config: env.config.clone(), + force, + key_file: Some(env.key_file.clone()), + dotfiles: Some(d.to_path_buf()), + check_prereqs: None, + env_injector: None, + }; + + // First run — setup hook should run + let r1 = run(make_opts(false)).unwrap(); + let step1 = r1.steps.iter().find(|s| s.name == "install-tools").unwrap(); + assert_eq!(step1.status, StepStatus::Ok, "first run should run setup hook"); + + // State file should exist + assert!(d.join(".nothooks-state.toml").exists()); + + // Second run — setup hook should be skipped + let r2 = run(make_opts(false)).unwrap(); + let step2 = r2.steps.iter().find(|s| s.name == "install-tools").unwrap(); + assert_eq!(step2.status, StepStatus::Skipped, "second run should skip setup hook"); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cargo nextest run -p integration --test bootstrap test_setup_hooks_skipped_on_rerun +``` + +Expected: PASS + +- [ ] **Step 5: Write test_bootstrap_fails_fast_on_bad_key** + +```rust +#[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("/nonexistent/no-such-key.age".into()), + 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").unwrap(); + assert!(matches!(key_step.status, StepStatus::Failed(_)), "bad key path should fail age key step"); + + // no link step should appear (stopped early) + let has_link = report.steps.iter().any(|s| s.name.starts_with("link dotfiles")); + assert!(!has_link, "should not reach link step after key failure"); +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +```bash +cargo nextest run -p integration --test bootstrap test_bootstrap_fails_fast_on_bad_key +``` + +Expected: PASS + +- [ ] **Step 7: Run all bootstrap tests together** + +```bash +cargo nextest run -p integration --test bootstrap +``` + +Expected: 3 passed + +- [ ] **Step 8: Commit** + +```bash +git add tests/integration/tests/bootstrap.rs +git commit -m "test(integration): add bootstrap flow tests via notstrap::run()" +``` + +--- + +## Task 4: Write cross_crate.rs tests + +**Files:** +- Create: `tests/integration/tests/cross_crate.rs` + +- [ ] **Step 1: Write test_nothooks_notsecrets_independent** + +```rust +use std::fs; +use tempfile::TempDir; +use notsecrets::{resolve_age_key, AgeKeySource, FileSource}; +use nothooks::{HookResult, HookRunner}; +use notcore::{HookPhase, HookSpec}; + +#[test] +fn test_nothooks_notsecrets_independent() { + let dir = TempDir::new().unwrap(); + + // Write a fake age key via FileSource + let key_path = dir.path().join("age.key"); + fs::write(&key_path, "AGE-SECRET-KEY-1CROSSCRATE\n").unwrap(); + + let sources: Vec> = vec![Box::new(FileSource::new(key_path))]; + let key = resolve_age_key(sources).unwrap(); + assert!(key.trim().starts_with("AGE-SECRET-KEY-")); + + // Write a hook that just prints (doesn't depend on the key value, just proves the chain works) + let script = dir.path().join("chain.nu"); + fs::write(&script, "print chain-ok\n").unwrap(); + + let spec = HookSpec { + name: "chain".to_string(), + script: script.to_str().unwrap().to_string(), + phase: HookPhase::Dot, + }; + + let runner = HookRunner::new(dir.path().to_path_buf()); + let result = runner.run_hook(&spec); + assert!(matches!(result, HookResult::Ok)); +} +``` + +- [ ] **Step 2: Run test to verify it passes** + +```bash +cargo nextest run -p integration --test cross_crate test_nothooks_notsecrets_independent +``` + +Expected: PASS + +- [ ] **Step 3: Write test_notfiles_respects_default_ignore** + +```rust +use std::fs; +use tempfile::TempDir; +use notfiles::{link, LinkOptions}; + +#[test] +fn test_notfiles_respects_default_ignore() { + let dotfiles = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + let d = dotfiles.path(); + + // notfiles.toml — package "pkg" targeting home tempdir + fs::write(d.join("notfiles.toml"), format!( + "[defaults]\nmethod = \"symlink\"\ntarget = \"{}\"\n", + home.path().display() + )).unwrap(); + + // Package dir with a normal file and two state files that should be ignored + let pkg = d.join("pkg"); + fs::create_dir_all(&pkg).unwrap(); + fs::write(pkg.join("foo.txt"), "hello\n").unwrap(); + fs::write(pkg.join(".notfiles-state.toml"), "# state\n").unwrap(); + fs::write(pkg.join(".nothooks-state.toml"), "# state\n").unwrap(); + + let opts = LinkOptions { force: false, no_backup: false, dry_run: false, verbose: false }; + let state = link(d, &[], &opts).unwrap(); + + // foo.txt linked + assert!(home.path().join("foo.txt").exists(), "foo.txt should be linked"); + + // state files NOT linked + assert!(!home.path().join(".notfiles-state.toml").exists(), + ".notfiles-state.toml must not be linked"); + assert!(!home.path().join(".nothooks-state.toml").exists(), + ".nothooks-state.toml must not be linked"); + + // state doesn't record them either + let names: Vec<_> = state.entries.iter().map(|e| e.source.file_name().unwrap().to_str().unwrap()).collect(); + assert!(!names.contains(&".notfiles-state.toml")); + assert!(!names.contains(&".nothooks-state.toml")); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cargo nextest run -p integration --test cross_crate test_notfiles_respects_default_ignore +``` + +Expected: PASS + +- [ ] **Step 5: Run all integration tests** + +```bash +cargo nextest run -p integration +``` + +Expected: 5 passed + +- [ ] **Step 6: Run full workspace to confirm nothing broken** + +```bash +cargo nextest run --workspace +``` + +Expected: all tests pass (existing count + 5 new). + +- [ ] **Step 7: Commit** + +```bash +git add tests/integration/tests/cross_crate.rs +git commit -m "test(integration): add cross-crate boundary tests" +``` + +--- + +## Task 5: Final cleanup and push + +- [ ] **Step 1: Run clippy on the whole workspace** + +```bash +cargo clippy --workspace -- -D warnings +``` + +Fix any warnings before proceeding. + +- [ ] **Step 2: Push to both remotes** + +```bash +git push gitea main && git push github main +``` + +- [ ] **Step 3: Mark doob todo as complete** + +```bash +doob todo complete 9 # "Write integration tests covering the full new-machine bootstrap flow" +```