Skip to content

Commit f8c7371

Browse files
CI: run Windows pnpm e2e tests instead of skipping (#1054) (#1407)
* Start fix for #1054 Assisted-by: Claude Code:claude-opus-5-5 * Run Windows pnpm e2e tests instead of skipping On windows-latest the pnpm-family e2e suites started pnpm and corepack with a bare Command::new, which only finds .exe files. npm and Node install both tools as .cmd shims there, so every availability probe said the tool was missing and every test skipped. libtest counts a skip as a pass, so the nightly Windows legs went green in 0.02 s while testing nothing. Add hermetic::tool_command, which resolves a tool through PATHEXT the same way the CLI does, and use it for every pnpm, corepack and npm spawn in e2e_safety_pnpm, e2e_redirect_pnpm_build and e2e_redirect_rush_sim, and in common::run_toolchain. Refs #1054 Assisted-by: Claude Code:claude-opus-5-5 * Fail Windows and macOS pnpm legs on a skip Set pnpm_required on the Windows and macOS rows of e2e_safety_pnpm, e2e_redirect_pnpm_build and e2e_redirect_rush_sim. A missing pnpm or corepack now fails the leg instead of letting it pass with every test skipped. These rows only run in the full tier (nightly and dispatch), so PR and merge-queue runs are unchanged. The workflow contract test now checks the gate on every OS, and a new check stops these suites from spawning bare pnpm, corepack or npm again. Refs #1054 Assisted-by: Claude Code:claude-opus-5-5 * Tighten pnpm probe and shim regression guard has_pnpm now needs `pnpm --version` to succeed, not just to spawn: a resolved .cmd shim starts through cmd.exe even when the shim is broken. The shim guard now also covers `.cmd` spellings, a suite-local has_command, and the shared run_toolchain that pnpm_run uses. Refs #1054 Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent efd8788 commit f8c7371

7 files changed

Lines changed: 77 additions & 23 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,8 +1704,7 @@ jobs:
17041704
# the matching matrix key, which the suites treat as unset.
17051705
SOCKET_PATCH_NPM_E2E_REQUIRED: ${{ matrix.npm_required || '' }}
17061706
# `pnpm_required` does the same for the pnpm-family suites
1707-
# (corepack pnpm / pnpm on PATH). Set on the Linux rows only; the
1708-
# Windows and macOS rows have not been checked against it yet.
1707+
# (corepack pnpm / pnpm on PATH), on every OS that runs them.
17091708
SOCKET_PATCH_PNPM_E2E_REQUIRED: ${{ matrix.pnpm_required || '' }}
17101709
SOCKET_PATCH_CARGO_E2E_REQUIRED: ${{ matrix.suite == 'e2e_safety_cargo_build' && '1' || '' }}
17111710
SOCKET_PATCH_UV_E2E_REQUIRED: ${{ matrix.uv != '' && '1' || '' }}
@@ -1946,11 +1945,13 @@ jobs:
19461945
include:
19471946
- os: windows-latest
19481947
suite: e2e_safety_pnpm
1948+
pnpm_required: '1'
19491949
- os: windows-latest
19501950
suite: e2e_redirect_npm_build
19511951
npm_required: '1'
19521952
- os: windows-latest
19531953
suite: e2e_redirect_rush_sim
1954+
pnpm_required: '1'
19541955
- os: windows-latest
19551956
suite: e2e_redirect_bun_build e2e_vendor_bun_build mode_migration_bun
19561957
bun: '1.4.2'
@@ -1963,7 +1964,7 @@ jobs:
19631964
- {os: windows-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix, vlt_store_linker: 'hardlink'}
19641965
- {os: windows-latest, suite: e2e_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix}
19651966
- {os: windows-latest, suite: e2e_vlt, vlt: '1.0.0-rc.14', test_filter: --include-ignored vlt_pinned_matrix}
1966-
- {os: windows-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'}
1967+
- {os: windows-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix', pnpm_required: '1'}
19671968
- {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: maven, maven: '3.9.16', test_filter: '--ignored maven_reactor'}
19681969
- {os: windows-latest, suite: e2e_vendor_jvm_build, jvm_tool: gradle, gradle: '8.14.3', java: '17', test_filter: '--ignored gradle_multi_project'}
19691970
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS != 'true' && (matrix.os == 'ubuntu-latest' && 'depot-ubuntu-24.04-4' || matrix.os == 'ubuntu-22.04' && 'depot-ubuntu-22.04-4') || matrix.os }}
@@ -1983,11 +1984,13 @@ jobs:
19831984
include:
19841985
- os: macos-latest
19851986
suite: e2e_safety_pnpm
1987+
pnpm_required: '1'
19861988
- os: macos-latest
19871989
suite: e2e_redirect_npm_build
19881990
npm_required: '1'
19891991
- os: macos-latest
19901992
suite: e2e_redirect_rush_sim
1993+
pnpm_required: '1'
19911994
- os: macos-latest
19921995
suite: e2e_redirect_bun_build
19931996
bun: '1.4.2'
@@ -2005,7 +2008,7 @@ jobs:
20052008
- {os: macos-latest, suite: mode_migration_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix}
20062009
- {os: macos-latest, suite: e2e_safety_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix, vlt_store_linker: 'hardlink'}
20072010
- {os: macos-latest, suite: e2e_vlt, vlt: '1.2.0', test_filter: --include-ignored vlt_pinned_matrix}
2008-
- {os: macos-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix'}
2011+
- {os: macos-latest, suite: e2e_redirect_pnpm_build, test_filter: '--ignored --skip pnpm_pinned_matrix', pnpm_required: '1'}
20092012
- {os: macos-latest, suite: e2e_redirect_uv_build, uv: '0.12.17'}
20102013
- {os: macos-latest, suite: e2e_vendor_pypi_build, uv: '0.12.17', test_filter: --include-ignored}
20112014
- {os: macos-latest, suite: e2e_vex_build, test_filter: 'poetry:: --ignored', poetry: '2.4.3'}

‎crates/socket-patch-cli/tests/common/hermetic.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,19 @@ pub fn binary_command() -> Command {
8888
command(Path::new(env!("CARGO_BIN_EXE_socket-patch")))
8989
}
9090

91+
/// A `Command` for the package-manager tool `name` on `PATH`, resolved with
92+
/// the CLI's `resolve_tool` (every `PATHEXT` extension on Windows). npm and Node install `pnpm`, `corepack` and `npm` as `.cmd`
93+
/// shims there, and a bare `Command::new("corepack")` only finds
94+
/// `corepack.exe`, so the pnpm suites' probes reported the tool missing and
95+
/// every Windows test skipped. A tool that isn't on `PATH` keeps the bare
96+
/// name, so spawning it still fails the way the probes expect.
97+
pub fn tool_command(name: &str) -> Command {
98+
Command::new(
99+
socket_patch_core::utils::process::resolve_tool(name)
100+
.map_or_else(|| name.into(), std::path::PathBuf::into_os_string),
101+
)
102+
}
103+
91104
/// Remove every ambient `SOCKET_*` var from `cmd`, except the telemetry
92105
/// opt-outs (an opted-out developer stays opted out), `SOCKET_NO_CONFIG`
93106
/// and `SOCKET_NO_UPDATE_CHECK`. [`command`] runs this; package-manager

‎crates/socket-patch-cli/tests/common/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ pub fn vlt_run(cwd: &Path, vlt_js: &Path, args: &[&str], extra_env: &[(&str, &st
279279
}
280280

281281
fn run_toolchain(cwd: &Path, exe: &str, args: &[&str], extra_env: &[(&str, &str)]) {
282-
let mut cmd = Command::new(exe);
282+
let mut cmd = hermetic::tool_command(exe);
283283
cmd.args(args).current_dir(cwd);
284284
// Sandbox the caches first so `extra_env` can still override any
285285
// individual one (`Command` env ops are last-write-wins per name).

‎crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ fn pnpm_command(pm: &str) -> Command {
124124
if let Some(bin) = std::env::var_os("SOCKET_PATCH_PNPM_E2E_BIN") {
125125
Command::new(bin)
126126
} else {
127-
let mut cmd = Command::new("corepack");
127+
let mut cmd = hermetic::tool_command("corepack");
128128
cmd.arg(pm);
129129
cmd
130130
}

‎crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ mod common;
2929
use common::binary;
3030

3131
use std::path::Path;
32-
use std::process::{Command, Output, Stdio};
32+
use std::process::{Output, Stdio};
3333

3434
use sha2::{Digest, Sha512};
3535
use wiremock::matchers::{method, path, path_regex};
@@ -82,7 +82,7 @@ fn has_corepack_pm(pm: &str) -> bool {
8282
};
8383
// Isolated too: this probe is what actually downloads the package manager
8484
// the first time, and corepack stores it under `COREPACK_HOME`.
85-
let mut cmd = Command::new("corepack");
85+
let mut cmd = hermetic::tool_command("corepack");
8686
cmd.args([pm, "--version"])
8787
.current_dir(probe.path())
8888
.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0");
@@ -95,7 +95,7 @@ fn has_corepack_pm(pm: &str) -> bool {
9595
}
9696

9797
fn has_command(cmd: &str) -> bool {
98-
let mut probe = Command::new(cmd);
98+
let mut probe = hermetic::tool_command(cmd);
9999
probe.arg("--version");
100100
cache_env::isolate(&mut probe);
101101
probe
@@ -107,7 +107,7 @@ fn has_command(cmd: &str) -> bool {
107107
}
108108

109109
fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Output {
110-
let mut cmd = Command::new("corepack");
110+
let mut cmd = hermetic::tool_command("corepack");
111111
cmd.arg(pm).args(args).current_dir(cwd);
112112
hermetic::scrub_socket_vars(&mut cmd);
113113
hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]);
@@ -652,7 +652,7 @@ async fn rush_hosted_real_rush_update_install() {
652652
let rush = |args: &[&str]| -> Output {
653653
let mut full = vec!["x", "-y", rush_pkg.as_str()];
654654
full.extend_from_slice(args);
655-
let mut cmd = Command::new("npm");
655+
let mut cmd = hermetic::tool_command("npm");
656656
cmd.args(&full).current_dir(root);
657657
hermetic::scrub_socket_vars(&mut cmd);
658658
hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]);

‎crates/socket-patch-cli/tests/e2e_safety_pnpm.rs‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use std::path::{Path, PathBuf};
2828
mod common;
2929

3030
use common::{
31-
assert_run_ok, git_sha256, git_sha256_file, has_command, pnpm_run, write_blob,
31+
assert_run_ok, git_sha256, git_sha256_file, hermetic, pnpm_run, write_blob,
3232
write_minimal_manifest, write_package_json, PatchEntry,
3333
};
3434

@@ -45,6 +45,22 @@ fn pnpm_required() -> bool {
4545
std::env::var_os("SOCKET_PATCH_PNPM_E2E_REQUIRED").is_some_and(|v| !v.is_empty())
4646
}
4747

48+
/// `pnpm --version` succeeds. Probed through [`hermetic::tool_command`]: on
49+
/// Windows pnpm is the `pnpm.cmd` shim `npm install -g` writes, which
50+
/// `common::has_command`'s bare `Command::new` never finds. The exit status
51+
/// counts too, since a resolved `.cmd` spawns through `cmd.exe` even when
52+
/// the shim itself is broken.
53+
fn has_pnpm() -> bool {
54+
let mut probe = hermetic::tool_command("pnpm");
55+
probe.arg("--version");
56+
common::cache_env::isolate(&mut probe);
57+
probe
58+
.stdout(std::process::Stdio::null())
59+
.stderr(std::process::Stdio::null())
60+
.status()
61+
.is_ok_and(|s| s.success())
62+
}
63+
4864
/// Print the SKIP line for a missing pnpm, or panic under [`pnpm_required`].
4965
fn skip_missing_pnpm() {
5066
assert!(
@@ -249,7 +265,7 @@ fn file_identity(path: &Path) -> (u64, u64) {
249265
#[test]
250266
#[ignore]
251267
fn pnpm_install_produces_symlinked_layout() {
252-
if !has_command("pnpm") {
268+
if !has_pnpm() {
253269
skip_missing_pnpm();
254270
return;
255271
}
@@ -313,7 +329,7 @@ fn pnpm_install_produces_symlinked_layout() {
313329
#[test]
314330
#[ignore]
315331
fn apply_in_a_does_not_mutate_b_or_store() {
316-
if !has_command("pnpm") {
332+
if !has_pnpm() {
317333
skip_missing_pnpm();
318334
return;
319335
}
@@ -417,7 +433,7 @@ fn apply_in_a_does_not_mutate_b_or_store() {
417433
#[test]
418434
#[ignore]
419435
fn pnpm_install_in_b_does_not_revert_a() {
420-
if !has_command("pnpm") {
436+
if !has_pnpm() {
421437
skip_missing_pnpm();
422438
return;
423439
}
@@ -532,7 +548,7 @@ fn pnpm_install_in_b_does_not_revert_a() {
532548
#[test]
533549
#[ignore]
534550
fn apply_in_pnpm_project_emits_layout_note() {
535-
if !has_command("pnpm") {
551+
if !has_pnpm() {
536552
skip_missing_pnpm();
537553
return;
538554
}

‎scripts/tests/test_ci_e2e_tiers.py‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -275,8 +275,8 @@ def test_compat_workflow_builds_its_own_binaries(self):
275275

276276
class PnpmRequiredGate(unittest.TestCase):
277277
"""The pnpm-family suites soft-skip when pnpm is missing, and libtest
278-
counts a skip as `ok`, so their Linux rows must set the fail-instead-of-
279-
skip gate (#1054)."""
278+
counts a skip as `ok`, so every row that runs them, on every OS, must set
279+
the fail-instead-of-skip gate (#1054)."""
280280

281281
suites = {"e2e_redirect_pnpm_build", "e2e_safety_pnpm", "e2e_redirect_rush_sim"}
282282
jobs = ("e2e", "e2e-full", "e2e-extended", "e2e-windows", "e2e-macos")
@@ -285,20 +285,42 @@ def test_run_step_reads_the_row_key(self):
285285
run = dict(rows_mod.steps(JOBS["e2e"]))["Run e2e tests"]
286286
self.assertIn("SOCKET_PATCH_PNPM_E2E_REQUIRED: ${{ matrix.pnpm_required || '' }}", run)
287287

288-
def test_every_linux_pnpm_row_sets_the_gate(self):
289-
gated = set()
288+
def test_every_pnpm_row_sets_the_gate(self):
289+
gated = {}
290290
for job in self.jobs:
291291
for row in rows(job):
292-
if self.suites & set(row["suite"].split()) and row["os"].startswith("ubuntu"):
292+
if self.suites & set(row["suite"].split()):
293293
with self.subTest(job=job, row=row):
294294
self.assertEqual(row.get("pnpm_required"), "1")
295-
gated |= self.suites & set(row["suite"].split())
295+
for suite in self.suites & set(row["suite"].split()):
296+
gated.setdefault(suite, set()).add(row["os"].split("-")[0])
296297
elif "pnpm_required" in row:
297298
self.fail(f"{job} row outside the checked set sets pnpm_required: {row}")
298-
self.assertEqual(gated, self.suites)
299+
self.assertEqual(gated, {s: {"ubuntu", "windows", "macos"} for s in self.suites})
299300
self.assertIn("e2e_redirect_pnpm_build", {r["suite"] for r in rows("e2e") if r.get("pnpm_required")},
300301
"the PR tier must keep its gated pnpm row")
301302

303+
def test_suites_resolve_windows_shims(self):
304+
# A bare `Command::new("pnpm")` / `Command::new("corepack")` can't
305+
# spawn the `.cmd` shims npm and Node install on Windows, so every
306+
# Windows test skipped (passed in 0.02 s) until the gate made that a
307+
# failure. `hermetic::tool_command` resolves them through PATHEXT.
308+
for suite in sorted(self.suites):
309+
with self.subTest(suite=suite):
310+
src = (TESTS / f"{suite}.rs").read_text(encoding="utf-8")
311+
self.assertNotRegex(src, r'Command::new\("(pnpm|corepack|npm)(\.cmd)?"\)')
312+
if "fn has_command(" in src:
313+
# A suite-local probe must resolve shims itself...
314+
local = src[src.index("fn has_command("):]
315+
self.assertIn("hermetic::tool_command(cmd)", local[:local.index("\n}\n")])
316+
else:
317+
# ...and `common::has_command` does not.
318+
self.assertNotRegex(src, r'has_command\("(pnpm|corepack|npm)"\)')
319+
# `pnpm_run` goes through the shared runner.
320+
common = (TESTS / "common" / "mod.rs").read_text(encoding="utf-8")
321+
runner = common[common.index("fn run_toolchain("):]
322+
self.assertIn("hermetic::tool_command(exe)", runner[:runner.index("\n}\n")])
323+
302324
def test_every_gated_suite_honours_the_gate(self):
303325
for suite in sorted(self.suites):
304326
with self.subTest(suite=suite):

0 commit comments

Comments
 (0)