Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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
60 changes: 59 additions & 1 deletion src/bin/e2e_workflow_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&registry_state)
.map_err(|error| format!("failed to load current Mason registry: {error}"))?;
let mut families = BTreeMap::new();
for server in manifest.downloadable_servers()? {
Expand All @@ -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(&registry_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"))
}
Expand All @@ -88,3 +116,33 @@ fn parse_selector(value: &str) -> Result<WorkflowSelector, String> {
_ => 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")
);
}
}
}
7 changes: 7 additions & 0 deletions src/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -50,6 +53,10 @@ pub(crate) fn path() -> Option<OsString> {
std::env::var_os(PATH)
}

pub(crate) fn install_path() -> Option<OsString> {
std::env::var_os(INSTALL_PATH)
}

pub(crate) fn shell() -> Option<OsString> {
std::env::var_os(SHELL)
}
Expand Down
28 changes: 17 additions & 11 deletions src/mason/install.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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")
Expand All @@ -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<bool> {
let Some(fake_program) = env_vars::fake_npm_program() else {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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!(
Expand Down
18 changes: 17 additions & 1 deletion src/mason/install/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()));
}
16 changes: 13 additions & 3 deletions src/mason/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PathBuf> {
Expand Down Expand Up @@ -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!(
Expand Down
38 changes: 36 additions & 2 deletions src/mason/link/tests.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -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));
}
22 changes: 14 additions & 8 deletions tests/e2e/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading