From 16e856a20307a3ca20524d96ea13348db7f2cffd Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 2 May 2026 17:40:07 +0900 Subject: feat(installer): add trace recorder and topo install order Adds TraceRecorderExecutor (Composer's InstallationManagerMock analog), which records every install/update/uninstall as a string matching Composer's *Operation::__toString output (after strip_tags) - the load-bearing assertion target for in-process fixture tests. Two changes were needed to make the recorder useful: - InstallerExecutor::uninstall_package gains a version parameter, and install_from_lock now looks up both the uninstall and the Update-from-version from installed.json. Previously the Update path passed the new version as a placeholder; the recorder needs the real old version to emit `Upgrading pkg (old => new)`. - compute_operations now topologically sorts the lock contents (deps before dependents) before computing actions, mirroring Composer's Transaction::calculateOperations. Without this, packages would install in alphabetical order and the trace would diverge from Composer's expectation. Also adds crates/mozart/tests/installer_in_process.rs with the in-process harness scaffold: parses the same .test fixtures, builds a tempdir, calls commands::install::run / update::run with an empty RepositorySet (no Packagist) and a TraceRecorderExecutor, then asserts exit code + EXPECT trace. One fixture wired up: suggest_replaced - the original CI failure that motivated this whole DI refactor. It now passes on the in-process path because the empty RepositorySet makes b/b unreachable just like Composer's `'packagist' => false` test config, and the resolver finds c/c (which replaces b/b) via the inline package repo's eager preload. Step F will migrate every fixture currently in installer.rs to the new harness; remaining divergences (alias handling, output ordering, replace trace shape, etc.) will surface as individual follow-ups. All 136 existing spawn-based fixtures + 114 mozart-registry tests + 541 mozart lib tests still green; clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/mozart/tests/installer_in_process.rs | 162 ++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 crates/mozart/tests/installer_in_process.rs (limited to 'crates/mozart/tests/installer_in_process.rs') diff --git a/crates/mozart/tests/installer_in_process.rs b/crates/mozart/tests/installer_in_process.rs new file mode 100644 index 0000000..f3e8ce2 --- /dev/null +++ b/crates/mozart/tests/installer_in_process.rs @@ -0,0 +1,162 @@ +//! In-process installer fixture runner. +//! +//! Mirrors Composer's PHPUnit-driven `InstallerTest`: parses the same +//! `.test` fixture files, sets up a tempdir with `composer.json` / +//! `composer.lock` / `vendor/composer/installed.json`, then invokes +//! `mozart::commands::{install,update}::run` directly with an empty +//! `RepositorySet` (Composer's `'packagist' => false` test config) and a +//! `TraceRecorderExecutor` (Composer's `InstallationManagerMock`). +//! +//! Step F will move every fixture in `installer.rs` over to this harness; +//! for now this file just demonstrates the path on a single fixture +//! (`suggest_replaced` — the original CI failure that motivated the whole +//! DI refactor). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use clap::Parser; +use mozart::commands::{Cli, Commands, install, update}; +use mozart_core::console::Console; +use mozart_core::exit_code::MozartError; +use mozart_registry::installer_executor::TraceRecorderExecutor; +use mozart_registry::repository::RepositorySet; +use mozart_test_harness::{ParsedTest, parse_test_file}; +use tempfile::TempDir; + +fn fixtures_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../composer/tests/Composer/Test/Fixtures/installer") +} + +/// Outcome of a single in-process fixture run. +struct InProcessRunResult { + /// Kept alive so the caller can inspect on-disk artifacts; dropped + /// (and removed) when this struct goes out of scope. + _working_dir: TempDir, + /// Composer-shape operation trace from `TraceRecorderExecutor`. + /// Compare against the fixture's `--EXPECT--` section. + trace: Vec, + /// Final `composer.lock` JSON, as written to disk by the runner. + final_lock: Option, + /// Final `vendor/composer/installed.json`, as written to disk. + final_installed: Option, + /// Mapped exit code: 0 for success, otherwise the carried + /// [`MozartError::exit_code`] (or 1 for unclassified errors). + exit_code: i32, +} + +async fn run_fixture_in_process(test: &ParsedTest) -> anyhow::Result { + let working_dir = TempDir::new()?; + let root = working_dir.path(); + + std::fs::write(root.join("composer.json"), &test.composer)?; + if let Some(lock) = &test.lock { + std::fs::write(root.join("composer.lock"), lock)?; + } + if let Some(installed) = &test.installed { + let vendor_composer = root.join("vendor").join("composer"); + std::fs::create_dir_all(&vendor_composer)?; + std::fs::write(vendor_composer.join("installed.json"), installed)?; + } + + // Parse the `--RUN--` line through clap so we get the same arg semantics + // the real CLI does — including default flags, validators, etc. + let argv: Vec = std::iter::once("mozart".to_string()) + .chain(test.run.split_whitespace().map(String::from)) + .collect(); + let cli = Cli::try_parse_from(&argv)?; + + // Quiet console: tests assert on `trace` / lock / installed, not on + // captured stdout/stderr (Console doesn't yet support buffered sinks). + let console = Console::new(0, true, false, true, true); + let repositories = Arc::new(RepositorySet::empty()); + let mut executor = TraceRecorderExecutor::new(); + + let outcome: anyhow::Result<()> = match &cli.command { + Some(Commands::Install(args)) => { + install::run(root, args, &console, repositories, &mut executor).await + } + Some(Commands::Update(args)) => { + update::run(root, args, &console, repositories, &mut executor).await + } + other => anyhow::bail!( + "unsupported run command in fixture: {:?}", + other.is_some() + ), + }; + + let exit_code = match &outcome { + Ok(()) => 0, + Err(e) => e + .downcast_ref::() + .map(|m| m.exit_code) + .unwrap_or(1), + }; + + let final_lock = std::fs::read_to_string(root.join("composer.lock")).ok(); + let final_installed = + std::fs::read_to_string(root.join("vendor").join("composer").join("installed.json")).ok(); + + Ok(InProcessRunResult { + _working_dir: working_dir, + trace: executor.into_trace(), + final_lock, + final_installed, + exit_code, + }) +} + +fn run_fixture(ident: &str) { + let filename = format!("{}.test", ident.replace('_', "-")); + let path = fixtures_dir().join(&filename); + let parsed = parse_test_file(&path) + .unwrap_or_else(|e| panic!("failed to parse {}: {:#}", path.display(), e)); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime"); + let result = runtime + .block_on(run_fixture_in_process(&parsed)) + .unwrap_or_else(|e| panic!("failed to run {}: {:#}", path.display(), e)); + + let expected_exit = parsed.expect_exit_code.unwrap_or(0); + assert_eq!( + result.exit_code, + expected_exit, + "exit code mismatch for {}\n--- trace ---\n{}", + path.display(), + result.trace.join("\n"), + ); + + // EXPECT (the trace) is the load-bearing assertion in Composer's + // PHPUnit harness — every line of the operation log must match + // byte-for-byte against `(string) $operation` after `strip_tags`. + let expected_trace = parsed.expect.trim(); + let actual_trace = result.trace.join("\n"); + assert_eq!( + actual_trace.trim(), + expected_trace, + "EXPECT trace mismatch for {}\n--- expected ---\n{}\n--- actual ---\n{}\n--- final lock ---\n{}\n--- final installed ---\n{}", + path.display(), + expected_trace, + actual_trace, + result.final_lock.as_deref().unwrap_or("(absent)"), + result.final_installed.as_deref().unwrap_or("(absent)"), + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// In-process fixtures +// +// Step F will migrate every fixture from `installer.rs` to this harness. +// For now this file holds just the proof-of-concept: `suggest_replaced`, +// the original CI failure (the spawn runner can't reach Packagist for +// `b/b`, even though `c/c` replaces it). +// ──────────────────────────────────────────────────────────────────────────── + +#[test] +fn suggest_replaced_in_process() { + run_fixture("suggest_replaced"); +} -- cgit v1.3.1