diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c29674d..c7b4734 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -49,10 +49,18 @@ jobs: E2E_SELECTOR_VALUE: ${{ github.event.inputs.value }} E2E_PLAN_OUTPUT: ${{ runner.temp }}/e2e-plan.json E2E_REPORT_OUTPUT: ${{ runner.temp }}/e2e-report.md + E2E_REGISTRY_SNAPSHOT_OUTPUT: ${{ runner.temp }}/mason-registry run: | cargo run --locked --features e2e-workflow-planner --bin e2e-workflow-plan echo "plan=$(<"$E2E_PLAN_OUTPUT")" >> "$GITHUB_OUTPUT" cat "$E2E_REPORT_OUTPUT" >> "$GITHUB_STEP_SUMMARY" + - name: Upload Mason registry snapshot + uses: actions/upload-artifact@v7 + with: + name: mason-registry-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/mason-registry + if-no-files-found: error + retention-days: 1 test: name: ${{ matrix.name }} @@ -69,6 +77,11 @@ jobs: uses: actions/checkout@v7 with: submodules: recursive + - name: Download Mason registry snapshot + uses: actions/download-artifact@v8 + with: + name: mason-registry-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/mason-registry - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - name: Cache Rust build artifacts @@ -97,4 +110,5 @@ jobs: - name: Run real-server smoke cases env: E2E_CASES: ${{ matrix.cases }} + E2E_MASON_REGISTRY_SNAPSHOT: ${{ runner.temp }}/mason-registry run: make test-real-server-smoke-e2e diff --git a/src/bin/e2e_workflow_plan.rs b/src/bin/e2e_workflow_plan.rs index afc1526..76806c2 100644 --- a/src/bin/e2e_workflow_plan.rs +++ b/src/bin/e2e_workflow_plan.rs @@ -40,13 +40,17 @@ use manifest::coverage_cases::{InstallationFamily, WorkflowSelector}; use mason::registry::MasonRegistry; use runtime_state::RuntimeState; +const REGISTRY_SNAPSHOT_OUTPUT_ENV: &str = "E2E_REGISTRY_SNAPSHOT_OUTPUT"; +const REGISTRY_SNAPSHOT_FILES: [&str; 2] = ["registry.json", "metadata.json"]; + fn main() -> Result<(), String> { let selector = parse_selector(&required_environment("E2E_SELECTOR")?)?; let value = std::env::var("E2E_SELECTOR_VALUE").ok(); let manifest = Manifest::load_validated(repository_root())?; let cache = tempfile::tempdir() .map_err(|error| format!("failed to create temporary Mason registry cache: {error}"))?; - let registry = MasonRegistry::load(&RuntimeState::new(cache.path().join("mason"))) + let registry_state = RuntimeState::new(cache.path().join("mason")); + let registry = MasonRegistry::load(®istry_state) .map_err(|error| format!("failed to load current Mason registry: {error}"))?; let mut families = BTreeMap::new(); for server in manifest.downloadable_servers()? { @@ -65,12 +69,36 @@ fn main() -> Result<(), String> { .map_err(|error| format!("failed to write workflow plan: {error}"))?; std::fs::write(required_environment("E2E_REPORT_OUTPUT")?, report) .map_err(|error| format!("failed to write workflow report: {error}"))?; + if let Ok(output) = std::env::var(REGISTRY_SNAPSHOT_OUTPUT_ENV) { + persist_registry_snapshot(®istry_state, Path::new(&output))?; + } // Explicit close prevents registry downloads from accumulating across workflow planning runs. cache .close() .map_err(|error| format!("failed to remove Mason registry cache: {error}")) } +fn persist_registry_snapshot(state: &RuntimeState, output: &Path) -> Result<(), String> { + std::fs::create_dir_all(output).map_err(|error| { + format!( + "failed to create Mason registry snapshot directory {}: {error}", + output.display() + ) + })?; + for name in REGISTRY_SNAPSHOT_FILES { + let source = state.registry_dir().join(name); + let destination = output.join(name); + std::fs::copy(&source, &destination).map_err(|error| { + format!( + "failed to copy Mason registry snapshot {} to {}: {error}", + source.display(), + destination.display() + ) + })?; + } + Ok(()) +} + fn repository_root() -> &'static Path { Path::new(env!("CARGO_MANIFEST_DIR")) } @@ -88,3 +116,33 @@ fn parse_selector(value: &str) -> Result { _ => Err(format!("unknown E2E selector {value:?}")), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persists_the_registry_files_used_by_the_plan() { + let source = tempfile::tempdir().expect("source directory should initialize"); + let output = tempfile::tempdir().expect("output directory should initialize"); + let state = RuntimeState::new(source.path().join("state")); + std::fs::create_dir_all(state.registry_dir()) + .expect("registry directory should be created"); + for (name, contents) in [ + ("registry.json", b"registry".as_slice()), + ("metadata.json", b"metadata".as_slice()), + ] { + std::fs::write(state.registry_dir().join(name), contents) + .expect("source file should be written"); + } + + persist_registry_snapshot(&state, output.path()).expect("snapshot should persist"); + + for name in REGISTRY_SNAPSHOT_FILES { + assert_eq!( + std::fs::read(output.path().join(name)).expect("snapshot should be readable"), + std::fs::read(state.registry_dir().join(name)).expect("source should be readable") + ); + } + } +} diff --git a/src/env_vars.rs b/src/env_vars.rs index bc8092b..6b9c63e 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -16,6 +16,9 @@ pub(crate) const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME"; /// Executable search path used to locate runtimes and already-installed servers. pub(crate) const PATH: &str = "PATH"; +/// Executable search path reserved for package installers and their build tools. +pub(crate) const INSTALL_PATH: &str = "LSP_CLI_INSTALL_PATH"; + /// Current interactive shell used for shell auto-detection in completion output. pub(crate) const SHELL: &str = "SHELL"; @@ -50,6 +53,10 @@ pub(crate) fn path() -> Option { std::env::var_os(PATH) } +pub(crate) fn install_path() -> Option { + std::env::var_os(INSTALL_PATH) +} + pub(crate) fn shell() -> Option { std::env::var_os(SHELL) } diff --git a/src/mason/install.rs b/src/mason/install.rs index 4b4c516..0bcb78a 100644 --- a/src/mason/install.rs +++ b/src/mason/install.rs @@ -1,7 +1,8 @@ +use crate::env_vars; use crate::error::{Error, Result}; use crate::mason::link::{ - ResolvedProgram, finalize_install, is_resolved_program_runnable, join_relative_path, - resolve_program, + ResolvedProgram, finalize_install, is_install_command_runnable, is_resolved_program_runnable, + join_relative_path, resolve_program, }; use crate::mason::platform::MasonPlatform; use crate::mason::registry::MasonPackage; @@ -127,7 +128,7 @@ fn install_npm_package( } let install_spec = format!("{package_name}@{version}"); - let mut cmd = Command::new("npm"); + let mut cmd = installer_command("npm"); cmd.arg("install") .arg("--no-package-lock") .arg("--prefix") @@ -148,9 +149,6 @@ fn install_npm_package( ) } -#[cfg(test)] -use crate::env_vars; - #[cfg(test)] fn fake_npm_install(install_dir: &std::path::Path, program: &str) -> Result { let Some(fake_program) = env_vars::fake_npm_program() else { @@ -245,7 +243,7 @@ fn pypi_install_command( } else { format!("{package_name}[{}]=={version}", extras.join(",")) }; - let mut command = Command::new("python3"); + let mut command = installer_command("python3"); command .arg("-m") .arg("pip") @@ -275,7 +273,7 @@ fn install_cargo_package( require_command("cargo", package, program)?; let install_dir = prepare_install_dir(state, package)?; - let mut cmd = Command::new("cargo"); + let mut cmd = installer_command("cargo"); cmd.arg("install") .arg("--root") .arg(&install_dir) @@ -319,7 +317,7 @@ fn install_golang_package( }; crate::fs::create_dir_all(bin_dir)?; - let mut cmd = Command::new("go"); + let mut cmd = installer_command("go"); cmd.arg("install") .arg(format!("{module_path}@{version}")) .env("GOBIN", bin_dir); @@ -374,7 +372,7 @@ fn nuget_install_command( version: &str, install_dir: &std::path::Path, ) -> Command { - let mut command = Command::new("dotnet"); + let mut command = installer_command("dotnet"); command .arg("tool") .arg("install") @@ -535,8 +533,16 @@ fn run_install_command(cmd: &mut Command, package: &MasonPackage, tool: &str) -> ensure_command_success(&output, package, tool) } +fn installer_command(program: &str) -> Command { + let mut command = Command::new(program); + if let Some(path) = env_vars::install_path() { + command.env(env_vars::PATH, path); + } + command +} + fn require_command(command: &str, package: &MasonPackage, program: &str) -> Result<()> { - if crate::mason::link::is_command_runnable(command) { + if is_install_command_runnable(command) { Ok(()) } else { Err(Error::unexpected(format!( diff --git a/src/mason/install/tests.rs b/src/mason/install/tests.rs index ee4ed32..3e68462 100644 --- a/src/mason/install/tests.rs +++ b/src/mason/install/tests.rs @@ -5,7 +5,7 @@ use std::fs; use super::{ artifacts::{command_failure_detail, parse_archive_file_spec}, - nuget_install_command, pypi_install_command, resolve_or_install_program, + installer_command, nuget_install_command, pypi_install_command, resolve_or_install_program, }; #[cfg(unix)] use crate::runtime_state::RuntimeState; @@ -117,3 +117,19 @@ fn installs_and_caches_nuget_tool_with_a_receipt() { .expect("installed NuGet tool should be reusable from cache"); assert_eq!(cached, installed); } + +#[cfg(unix)] +#[test] +fn installer_command_uses_the_dedicated_toolchain_path() { + let dir = TestDir::new("mason-installer-path"); + let path = dir.path().join("toolchain"); + + let command = with_env_vars(&[env_var("LSP_CLI_INSTALL_PATH", &path)], || { + installer_command("npm") + }); + let actual = command + .get_envs() + .find_map(|(name, value)| (name == "PATH").then_some(value).flatten()); + + assert_eq!(actual, Some(path.as_os_str())); +} diff --git a/src/mason/link.rs b/src/mason/link.rs index effe801..56ebbf1 100644 --- a/src/mason/link.rs +++ b/src/mason/link.rs @@ -7,6 +7,7 @@ use crate::runtime_state::RuntimeState; use crate::suggest::SuggestedLanguage; use serde::Serialize; use std::env; +use std::ffi::OsStr; use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -203,15 +204,24 @@ pub(crate) fn rewrite_program(suggestion: &SuggestedLanguage, program: &Path) -> } pub(crate) fn is_command_runnable(program: &str) -> bool { + is_command_runnable_in(program, env_vars::path().as_deref()) +} + +pub(crate) fn is_install_command_runnable(program: &str) -> bool { + let path = env_vars::install_path().or_else(env_vars::path); + is_command_runnable_in(program, path.as_deref()) +} + +fn is_command_runnable_in(program: &str, path: Option<&OsStr>) -> bool { if program.contains(std::path::MAIN_SEPARATOR) { return is_command_runnable_path(Path::new(program)); } - let Some(path) = env_vars::path() else { + let Some(path) = path else { return false; }; - env::split_paths(&path).any(|entry| is_command_runnable_path(&entry.join(program))) + env::split_paths(path).any(|entry| is_command_runnable_path(&entry.join(program))) } pub(crate) fn join_relative_path(root: &Path, relative: &str) -> Result { @@ -336,7 +346,7 @@ fn shell_quote(path: &Path) -> String { } fn require_command(command: &str, package: &MasonPackage, program: &str) -> Result<()> { - if is_command_runnable(command) { + if is_install_command_runnable(command) { Ok(()) } else { Err(Error::unexpected(format!( diff --git a/src/mason/link/tests.rs b/src/mason/link/tests.rs index 3d8eb80..3ce49db 100644 --- a/src/mason/link/tests.rs +++ b/src/mason/link/tests.rs @@ -1,6 +1,6 @@ use super::{ - ResolvedProgram, WrapperRuntime, is_command_runnable, is_resolved_program_runnable, - join_relative_path, resolve_program, rewrite_program, + ResolvedProgram, WrapperRuntime, is_command_runnable, is_install_command_runnable, + is_resolved_program_runnable, join_relative_path, resolve_program, rewrite_program, }; use crate::error::Result; use crate::mason::registry::{ @@ -476,3 +476,37 @@ fn detects_runnable_command_on_path() { assert!(detected); } + +#[cfg(unix)] +#[test] +fn separates_server_lookup_from_installer_tool_lookup() { + let dir = TestDir::new("mason-link-paths"); + let server_bin = dir.path().join("servers"); + let installer_bin = dir.path().join("installers"); + fs::create_dir_all(&server_bin).expect("server bin should be created"); + fs::create_dir_all(&installer_bin).expect("installer bin should be created"); + let rust_analyzer = server_bin.join("rust-analyzer"); + let npm = installer_bin.join("npm"); + fs::write(&rust_analyzer, b"stub\n").expect("server should be written"); + fs::write(&npm, b"stub\n").expect("installer should be written"); + make_executable(&rust_analyzer); + make_executable(&npm); + + let (server_visible, npm_visible, installer_npm, installer_server) = with_env_vars( + &[ + env_var("PATH", &server_bin), + env_var("LSP_CLI_INSTALL_PATH", &installer_bin), + ], + || { + ( + is_command_runnable("rust-analyzer"), + is_command_runnable("npm"), + is_install_command_runnable("npm"), + is_install_command_runnable("rust-analyzer"), + ) + }, + ); + + assert_eq!((server_visible, npm_visible), (true, false)); + assert_eq!((installer_npm, installer_server), (true, false)); +} diff --git a/tests/e2e/Readme.md b/tests/e2e/Readme.md index 09e9a13..0c609c3 100644 --- a/tests/e2e/Readme.md +++ b/tests/e2e/Readme.md @@ -157,7 +157,10 @@ Each test process sets at least: - `XDG_CONFIG_HOME` to an isolated configuration directory; - `XDG_RUNTIME_DIR` to an isolated daemon directory; - `LSP_DATA` to the pinned repository submodule; -- `PATH` to the explicitly provisioned toolchain/server environment. +- `PATH` to the isolated server directory, preventing ambient server executables from satisfying + `--download`; real-server cases pass the host toolchain path separately to Mason package + installers, while manifest-declared runtime programs are explicitly staged into the isolated + directory. Do not rely on a developer's user configuration, downloaded server cache, daemon sockets, current shell, or ambient server versions. @@ -304,8 +307,10 @@ default. Selecting a compatible pair with no E2E behavior fails with a clear err silently running no tests. These commands download external tools and require the host programs declared by the manifest, and -they use the current Mason registry — compare the resulting source ID with an earlier run before -concluding that local behavior has changed. +local runs use the current Mason registry — compare the resulting source ID with an earlier run +before concluding that local behavior has changed. CI sets `E2E_MASON_REGISTRY_SNAPSHOT` to the +registry snapshot created by its planner; the harness rejects incomplete snapshots instead of +silently downloading different metadata. The manual **End-to-end compatibility** GitHub Actions workflow can select `language`, `server`, or `installation-family` (the value is respectively a case language ID, an LSP config ID, or one of @@ -330,11 +335,12 @@ planner resolves installation families from the current Mason registry rather th registry metadata into `cases/`. Suite-level smoke, lifecycle, and provisioning deadlines provide common defaults; cases only declare intentional overrides. -Jobs never share homes, daemon runtime directories, or mutable workspaces. CI may cache immutable -download transport data, but each case retains isolated runtime state and never substitutes a -separately installed server for `--download`. Every real-server case tears down its isolated home -and temporary roots (Mason packages, Go module/build caches, other server download state) before -the next case starts; only immutable Rust build artifacts are shared by CI. +Jobs never share homes, daemon runtime directories, or mutable workspaces. The planner uploads one +immutable, verified Mason registry snapshot for all shards; each case copies that snapshot into its +own runtime state. A case never substitutes a separately installed server for `--download`. Every +real-server case tears down its isolated home and temporary roots (Mason packages, Go module/build +caches, other server download state) before the next case starts; only immutable Rust build +artifacts and registry input are shared by CI. Split CI (fast PR smoke + exhaustive nightly) trades "a regression affecting a non-preferred server may surface the following night rather than on the originating PR" for much lower latency, cost, diff --git a/tests/e2e/harness.rs b/tests/e2e/harness.rs index b69baf0..f9811c3 100644 --- a/tests/e2e/harness.rs +++ b/tests/e2e/harness.rs @@ -7,7 +7,6 @@ use std::process::Command; use std::time::Duration; use fs_extra::dir::CopyOptions; -use serde::de::DeserializeOwned; use tempfile::TempDir; use crate::process::{self, ProcessOutput}; @@ -22,11 +21,17 @@ mod lifecycle_support; pub(crate) use lifecycle_support::SocketSnapshot; #[path = "harness/cache_cleanup.rs"] mod cache_cleanup; +#[path = "harness/environment.rs"] +mod environment; #[path = "harness/failure_diagnostics.rs"] mod failure_diagnostics; +#[path = "harness/process_state.rs"] +mod process_state; + +use self::environment::INSTALL_PATH_ENV; +use self::process_state::runtime_state; const DEFAULT_COMMAND_DEADLINE: Duration = Duration::from_secs(30); -const DAEMON_CLEANUP_DEADLINE: Duration = Duration::from_secs(5); pub(crate) struct E2eContext { _sandbox: TempDir, @@ -39,6 +44,7 @@ pub(crate) struct E2eContext { bin_dir: PathBuf, build_dir: PathBuf, data_dir: PathBuf, + install_path: Option, // The staged `dotnet` apphost resolves its runtime via DOTNET_ROOT rather than PATH, so its // install root must be threaded through explicitly once `stage_host_program` resolves it. dotnet_root: RefCell>, @@ -101,6 +107,7 @@ impl E2eContext { bin_dir, build_dir, data_dir: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"), + install_path: None, dotnet_root: RefCell::new(None), ruby_env: RefCell::new(None), }) @@ -299,7 +306,7 @@ impl E2eContext { .env("XDG_CONFIG_HOME", &self.config_home) .env("XDG_RUNTIME_DIR", &self.runtime_dir) .env("LSP_DATA", &self.data_dir) - .env("PATH", &self.bin_dir) + .env("PATH", self.process_path()) .env("TMPDIR", &self.temp_dir) .env("LANG", "C") .env("LC_ALL", "C") @@ -308,6 +315,9 @@ impl E2eContext { if let Some(root) = self.dotnet_root.borrow().as_deref() { command.env("DOTNET_ROOT", root); } + if let Some(path) = self.install_path.as_deref() { + command.env(INSTALL_PATH_ENV, path); + } if let Some(ruby_env) = self.ruby_env.borrow().as_ref() { command .env("LD_LIBRARY_PATH", &ruby_env.lib_dir) @@ -338,113 +348,6 @@ impl E2eContext { } } -impl Drop for E2eContext { - fn drop(&mut self) { - let daemon_root = self.runtime_dir.join("lsp-cli"); - if !daemon_root.exists() { - return; - } - - // Detached daemons outlive command process groups, so the context must stop them explicitly. - let mut command = self.command(); - command.args(["stop-all", "--debug"]); - let cleanup = process::run(&mut command, DAEMON_CLEANUP_DEADLINE); - let diagnostic = match cleanup { - Ok(output) if output.status().success() => return, - Ok(output) => output.diagnostic( - "E2E daemon cleanup exited unsuccessfully", - &runtime_state(&self.runtime_dir), - ), - Err(failure) => failure.diagnostic(&runtime_state(&self.runtime_dir)), - }; - if std::thread::panicking() { - eprintln!("E2E daemon cleanup failed:\n{diagnostic}"); - } else { - panic!("E2E daemon cleanup failed:\n{diagnostic}"); - } - } -} - -impl E2eOutput { - pub(crate) fn assert_success(&self) { - self.ensure_success() - .unwrap_or_else(|diagnostic| panic!("{diagnostic}")); - } - - pub(crate) fn ensure_success(&self) -> Result<(), String> { - if self.process.status().success() { - Ok(()) - } else { - Err(self.diagnostic("lsp-cli exited unsuccessfully")) - } - } - - pub(crate) fn stdout_text(&self) -> &str { - std::str::from_utf8(self.process.stdout()).unwrap_or_else(|error| { - panic!( - "{}", - self.diagnostic(&format!("stdout is not valid UTF-8: {error}")) - ) - }) - } - - pub(crate) fn assert_stdout_contains(&self, expected: &str) { - if !self.stdout_text().contains(expected) { - panic!( - "{}", - self.diagnostic(&format!("stdout does not contain {expected:?}")) - ); - } - } - - pub(crate) fn stderr_text(&self) -> &str { - std::str::from_utf8(self.process.stderr()).unwrap_or_else(|error| { - panic!( - "{}", - self.diagnostic(&format!("stderr is not valid UTF-8: {error}")) - ) - }) - } - - pub(crate) fn json(&self) -> T { - self.try_json() - .unwrap_or_else(|diagnostic| panic!("{diagnostic}")) - } - - fn diagnostic(&self, reason: &str) -> String { - self.process - .diagnostic(reason, &runtime_state(&self.runtime_dir)) - } - - pub(crate) fn try_json(&self) -> Result { - serde_json::from_slice(self.process.stdout()) - .map_err(|error| self.diagnostic(&format!("stdout is not valid JSON: {error}"))) - } -} - -fn runtime_state(runtime_dir: &std::path::Path) -> String { - let daemon_root = runtime_dir.join("lsp-cli"); - let entries = match fs::read_dir(&daemon_root) { - Ok(entries) => entries, - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return format!("{} does not exist", daemon_root.display()); - } - Err(error) => return format!("failed to read {}: {error}", daemon_root.display()), - }; - let mut paths = entries - .map(|entry| match entry { - Ok(entry) => entry.path().display().to_string(), - Err(error) => format!(""), - }) - .collect::>(); - paths.sort(); - if paths.is_empty() { - format!("{} is empty", daemon_root.display()) - } else { - paths.join("\n") - } -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; diff --git a/tests/e2e/harness/cache_cleanup.rs b/tests/e2e/harness/cache_cleanup.rs index cc03056..17ab87f 100644 --- a/tests/e2e/harness/cache_cleanup.rs +++ b/tests/e2e/harness/cache_cleanup.rs @@ -17,7 +17,21 @@ impl E2eContext { operation: impl FnOnce(&Self) -> Result<(), String>, ) -> Result<(), String> { let context = Self::new() - .map_err(|error| format!("failed to create an isolated E2E context: {error}"))?; + .map_err(|error| format!("failed to create an isolated E2E context: {error}")); + Self::run_cleaned_context(context, operation) + } + + pub(crate) fn run_cleaned_real_server( + operation: impl FnOnce(&Self) -> Result<(), String>, + ) -> Result<(), String> { + Self::run_cleaned_context(Self::new_for_real_server(), operation) + } + + fn run_cleaned_context( + context: Result, + operation: impl FnOnce(&Self) -> Result<(), String>, + ) -> Result<(), String> { + let context = context?; let [cache_root, runtime_root] = context.isolated_roots(); let result = operation(&context); let retained = context.retained_failure_state(); diff --git a/tests/e2e/harness/environment.rs b/tests/e2e/harness/environment.rs new file mode 100644 index 0000000..d49fbe2 --- /dev/null +++ b/tests/e2e/harness/environment.rs @@ -0,0 +1,232 @@ +use std::ffi::OsString; +use std::fs; +use std::path::Path; + +use serde::Deserialize; +use serde_json::Value; + +use super::E2eContext; + +pub(crate) const REGISTRY_SNAPSHOT_ENV: &str = "E2E_MASON_REGISTRY_SNAPSHOT"; +pub(super) const INSTALL_PATH_ENV: &str = "LSP_CLI_INSTALL_PATH"; +const RUNTIME_STATE_DIR: &str = ".local/share/lsp-cli"; +const REGISTRY_FILES: [&str; 2] = ["registry.json", "metadata.json"]; + +#[derive(Deserialize)] +struct RegistryMetadata { + release_tag: String, + refreshed_at_epoch_seconds: u64, + digest: Option, +} + +impl E2eContext { + pub(crate) fn new_for_real_server() -> Result { + let mut context = Self::new() + .map_err(|error| format!("failed to create an isolated E2E context: {error}"))?; + context.install_path = std::env::var_os("PATH"); + if let Some(snapshot) = std::env::var_os(REGISTRY_SNAPSHOT_ENV) { + context.seed_registry_snapshot(Path::new(&snapshot))?; + } + Ok(context) + } + + pub(super) fn process_path(&self) -> OsString { + self.bin_dir.as_os_str().to_owned() + } + + fn seed_registry_snapshot(&self, source: &Path) -> Result<(), String> { + validate_registry_snapshot(source)?; + let destination = self.registry_dir(); + fs::create_dir_all(&destination).map_err(|error| { + format!( + "failed to create Mason registry snapshot destination {}: {error}", + destination.display() + ) + })?; + for name in REGISTRY_FILES { + let source_file = source.join(name); + let destination_file = destination.join(name); + fs::copy(&source_file, &destination_file).map_err(|error| { + format!( + "failed to copy Mason registry snapshot {} to {}: {error}", + source_file.display(), + destination_file.display() + ) + })?; + } + Ok(()) + } + + fn registry_dir(&self) -> std::path::PathBuf { + self.home.join(RUNTIME_STATE_DIR).join("registry") + } + + #[cfg(test)] + pub(super) fn use_install_path(&mut self, path: OsString) { + self.install_path = Some(path); + } + + #[cfg(test)] + pub(super) fn seed_test_registry_snapshot(&self, source: &Path) -> Result<(), String> { + self.seed_registry_snapshot(source) + } + + #[cfg(test)] + pub(super) fn test_registry_dir(&self) -> std::path::PathBuf { + self.registry_dir() + } +} + +fn validate_registry_snapshot(source: &Path) -> Result<(), String> { + let registry_path = source.join(REGISTRY_FILES[0]); + let registry = read_json::>(®istry_path)?; + if registry.is_empty() { + return Err(format!( + "Mason registry snapshot {} contains no packages", + registry_path.display() + )); + } + + let metadata_path = source.join(REGISTRY_FILES[1]); + let metadata = read_json::(&metadata_path)?; + if metadata.release_tag.is_empty() || metadata.refreshed_at_epoch_seconds == 0 { + return Err(format!( + "Mason registry snapshot metadata {} is incomplete", + metadata_path.display() + )); + } + if metadata + .digest + .as_deref() + .is_some_and(|digest| !digest.starts_with("sha256:")) + { + return Err(format!( + "Mason registry snapshot metadata {} has an unsupported digest", + metadata_path.display() + )); + } + Ok(()) +} + +fn read_json(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| { + format!( + "failed to read Mason registry snapshot {}: {error}", + path.display() + ) + })?; + serde_json::from_slice(&bytes).map_err(|error| { + format!( + "failed to parse Mason registry snapshot {}: {error}", + path.display() + ) + }) +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + fn context() -> E2eContext { + E2eContext::new().expect("E2E context should initialize") + } + + fn write_valid_snapshot(directory: &Path) -> Vec { + fs::create_dir(directory).expect("snapshot directory should be created"); + let registry = br#"[{"name":"server"}]"#.to_vec(); + fs::write(directory.join(REGISTRY_FILES[0]), ®istry) + .expect("registry should be written"); + let refreshed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_secs(); + let metadata = serde_json::json!({ + "release_tag": "2026-09-16", + "refreshed_at_epoch_seconds": refreshed, + "digest": "sha256:0123" + }); + fs::write( + directory.join(REGISTRY_FILES[1]), + serde_json::to_vec(&metadata).expect("metadata should serialize"), + ) + .expect("metadata should be written"); + registry + } + + #[test] + fn real_server_isolates_server_lookup_and_preserves_installer_tools() { + let mut context = context(); + let host_paths = [ + context.workspace.join("host-one"), + context.workspace.join("host-two"), + ]; + let install_path = std::env::join_paths(&host_paths).expect("host PATH should join"); + context.use_install_path(install_path.clone()); + + let actual = std::env::split_paths(&context.process_path()).collect::>(); + let command = context.command(); + let actual_install_path = command + .get_envs() + .find_map(|(name, value)| (name == INSTALL_PATH_ENV).then_some(value).flatten()); + + assert_eq!(actual, [context.bin_dir.clone()]); + assert_eq!(actual_install_path, Some(install_path.as_os_str())); + } + + #[test] + fn registry_snapshot_is_copied_into_isolated_home() { + let context = context(); + let source = context.workspace.join("snapshot"); + let registry = write_valid_snapshot(&source); + + context + .seed_test_registry_snapshot(&source) + .expect("snapshot should be seeded"); + + assert_eq!( + fs::read(context.test_registry_dir().join(REGISTRY_FILES[0])) + .expect("seeded registry should be readable"), + registry + ); + assert!( + context + .test_registry_dir() + .join(REGISTRY_FILES[1]) + .is_file() + ); + } + + #[test] + fn incomplete_registry_snapshot_fails_before_copying() { + let context = context(); + let source = context.workspace.join("snapshot"); + fs::create_dir(&source).expect("snapshot directory should be created"); + fs::write(source.join(REGISTRY_FILES[0]), br#"[{"name":"server"}]"#) + .expect("registry should be written"); + + let error = context + .seed_test_registry_snapshot(&source) + .expect_err("incomplete snapshot should fail"); + + assert!(error.contains("failed to read Mason registry snapshot")); + assert!(!context.test_registry_dir().exists()); + } + + #[test] + fn malformed_registry_snapshot_fails_before_copying() { + let context = context(); + let source = context.workspace.join("snapshot"); + write_valid_snapshot(&source); + fs::write(source.join(REGISTRY_FILES[0]), b"{not-json]") + .expect("invalid registry should be written"); + + let error = context + .seed_test_registry_snapshot(&source) + .expect_err("malformed snapshot should fail"); + + assert!(error.contains("failed to parse Mason registry snapshot")); + assert!(!context.test_registry_dir().exists()); + } +} diff --git a/tests/e2e/harness/process_state.rs b/tests/e2e/harness/process_state.rs new file mode 100644 index 0000000..4a29927 --- /dev/null +++ b/tests/e2e/harness/process_state.rs @@ -0,0 +1,119 @@ +use std::fs; +use std::io; +use std::path::Path; +use std::time::Duration; + +use serde::de::DeserializeOwned; + +use crate::process; + +use super::{E2eContext, E2eOutput}; + +const DAEMON_CLEANUP_DEADLINE: Duration = Duration::from_secs(5); + +impl Drop for E2eContext { + fn drop(&mut self) { + let daemon_root = self.runtime_dir.join("lsp-cli"); + if !daemon_root.exists() { + return; + } + + // Detached daemons outlive command process groups, so the context must stop them explicitly. + let mut command = self.command(); + command.args(["stop-all", "--debug"]); + let cleanup = process::run(&mut command, DAEMON_CLEANUP_DEADLINE); + let diagnostic = match cleanup { + Ok(output) if output.status().success() => return, + Ok(output) => output.diagnostic( + "E2E daemon cleanup exited unsuccessfully", + &runtime_state(&self.runtime_dir), + ), + Err(failure) => failure.diagnostic(&runtime_state(&self.runtime_dir)), + }; + if std::thread::panicking() { + eprintln!("E2E daemon cleanup failed:\n{diagnostic}"); + } else { + panic!("E2E daemon cleanup failed:\n{diagnostic}"); + } + } +} + +impl E2eOutput { + pub(crate) fn assert_success(&self) { + self.ensure_success() + .unwrap_or_else(|diagnostic| panic!("{diagnostic}")); + } + + pub(crate) fn ensure_success(&self) -> Result<(), String> { + if self.process.status().success() { + Ok(()) + } else { + Err(self.diagnostic("lsp-cli exited unsuccessfully")) + } + } + + pub(crate) fn stdout_text(&self) -> &str { + std::str::from_utf8(self.process.stdout()).unwrap_or_else(|error| { + panic!( + "{}", + self.diagnostic(&format!("stdout is not valid UTF-8: {error}")) + ) + }) + } + + pub(crate) fn assert_stdout_contains(&self, expected: &str) { + if !self.stdout_text().contains(expected) { + panic!( + "{}", + self.diagnostic(&format!("stdout does not contain {expected:?}")) + ); + } + } + + pub(crate) fn stderr_text(&self) -> &str { + std::str::from_utf8(self.process.stderr()).unwrap_or_else(|error| { + panic!( + "{}", + self.diagnostic(&format!("stderr is not valid UTF-8: {error}")) + ) + }) + } + + pub(crate) fn json(&self) -> T { + self.try_json() + .unwrap_or_else(|diagnostic| panic!("{diagnostic}")) + } + + pub(super) fn diagnostic(&self, reason: &str) -> String { + self.process + .diagnostic(reason, &runtime_state(&self.runtime_dir)) + } + + pub(crate) fn try_json(&self) -> Result { + serde_json::from_slice(self.process.stdout()) + .map_err(|error| self.diagnostic(&format!("stdout is not valid JSON: {error}"))) + } +} + +pub(super) fn runtime_state(runtime_dir: &Path) -> String { + let daemon_root = runtime_dir.join("lsp-cli"); + let entries = match fs::read_dir(&daemon_root) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return format!("{} does not exist", daemon_root.display()); + } + Err(error) => return format!("failed to read {}: {error}", daemon_root.display()), + }; + let mut paths = entries + .map(|entry| match entry { + Ok(entry) => entry.path().display().to_string(), + Err(error) => format!(""), + }) + .collect::>(); + paths.sort(); + if paths.is_empty() { + format!("{} is empty", daemon_root.display()) + } else { + paths.join("\n") + } +} diff --git a/tests/e2e/real_server_support.rs b/tests/e2e/real_server_support.rs index d99a93e..7ff881d 100644 --- a/tests/e2e/real_server_support.rs +++ b/tests/e2e/real_server_support.rs @@ -37,7 +37,7 @@ pub(crate) fn run_isolated_case<'a>( deadline: &CaseDeadline, operation: impl FnOnce(&E2eContext) -> Result<(), String>, ) -> Result<(), String> { - E2eContext::run_cleaned(|context| { + E2eContext::run_cleaned_real_server(|context| { context.copy_project(project)?; for (name, resolver) in host_programs { context.stage_host_program(name, resolver, deadline.remaining()?)?;