Skip to content
Merged
Changes from 1 commit
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
Next Next commit
test(e2e): RED — pin yarn PnP scan silent no-op + unreachable apply r…
…efusal

On any yarn Plug'n'Play project, all three scan modes exit 0 /
status=success / scannedPackages=0 with no warning (the crawler leg is
empty because node_modules is absent, and the lockfile-supplement leg
swallows the flavor probe's PnP diagnosis), and apply exits 0 with the
calm noManifest status because the documented yarn_pnp_unsupported
refusal sits below the noManifest early-return — unreachable when scan
never wrote a manifest.

These tests fail on current main (7 failed / 20 passed / 3 ignored):
- scan --json --yes --mode agent|hosted|vendored on a PnP fixture must
  surface an explicit warnings[] refusal (exit semantics deliberately
  unchanged) and touch no file
- human-mode scan must print the refusal to stderr
- the pnpm node-linker=pnp twin surfaces its own diagnosis
- apply --json / human with NO manifest must refuse loudly with the
  existing yarn_pnp_unsupported envelope, not silent noManifest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
  • Loading branch information
mikolalysenko and claude committed Aug 18, 2026
commit 7e442ba53707be6d1baffbc443ebdac2295dd43d
298 changes: 298 additions & 0 deletions crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,304 @@ fn pnp_project_with_no_npm_patches_still_applies_its_other_patches() {
);
}

// ── the silent no-op (P0) ────────────────────────────────────────────────────
//
// Under PnP, `node_modules/` is absent, so the crawler leg of scan discovery
// is empty; the lockfile-supplement leg used to swallow the flavor probe's
// PnP diagnosis and return nothing. Scan then hit its `package_count == 0`
// early-return and printed `status: success` / `scannedPackages: 0` with NO
// warning — in ALL THREE modes — and, because scan wrote no manifest,
// apply's documented loud refusal above was unreachable (apply exited 0 with
// the calm `noManifest` status). The user believed they were protected;
// nothing was checked. Reproduced on yarn 2.4.3 / 3.8.7 / 4.6.0, plain PnP
// and zero-install. The tests below pin the fix: an explicit
// `yarn_pnp_unsupported` refusal warning in scan's JSON envelope (exit
// semantics deliberately unchanged: still exit 0 / status success), a stderr
// line in human mode, and a loud apply refusal even without a manifest.

/// Recursively snapshot every file under `root` as relative path → git
/// sha256, for whole-tree no-mutation assertions.
fn snapshot_tree(root: &Path) -> std::collections::BTreeMap<String, String> {
fn walk(root: &Path, dir: &Path, out: &mut std::collections::BTreeMap<String, String>) {
for entry in std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()))
{
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
walk(root, &path, out);
} else {
let rel = path
.strip_prefix(root)
.expect("walk stays under root")
.to_string_lossy()
.to_string();
out.insert(rel, common::git_sha256_file(&path));
}
}
}
let mut out = std::collections::BTreeMap::new();
walk(root, root, &mut out);
out
}

/// The scan-side fixture: [`make_yarn_berry_project`] plus the berry
/// lockfile and a committed cache zip, mirroring a real PnP (zero-install)
/// checkout. No `node_modules/` — under PnP there is none, which is exactly
/// why the crawler leg finds nothing.
fn make_yarn_berry_scan_fixture(cwd: &Path) {
make_yarn_berry_project(cwd);
std::fs::write(
cwd.join("yarn.lock"),
"# This file is generated by running \"yarn install\" inside your project.\n\
# Manifest files (package.json) are also used.\n\n\
__metadata:\n version: 8\n cacheKey: 10c0\n\n\
\"dummy@npm:1.0.0\":\n version: 1.0.0\n resolution: \"dummy@npm:1.0.0\"\n checksum: 10c0/abc\n",
)
.expect("write berry yarn.lock");
std::fs::write(
cwd.join(".yarn")
.join("cache")
.join("dummy-npm-1.0.0-abc123-10c0.zip"),
b"PK\x05\x06 stub zip bytes",
)
.expect("write cache zip");
}

/// Assert the scan envelope carries the explicit PnP refusal warning:
/// a run-level `warnings[]` entry with the stable code (same vocabulary as
/// apply's refusal) whose detail names the layout and the workaround.
fn assert_scan_pnp_refusal_warning(env: &serde_json::Value, code: &str, ctx: &str) {
let warnings = env
.get("warnings")
.and_then(|w| w.as_array())
.unwrap_or_else(|| {
panic!("{ctx}: scan on a PnP project must carry a warnings[] refusal (the silent success-0 P0).\nenvelope: {env}")
});
let w = warnings
.iter()
.find(|w| json_string(w, "code") == Some(code))
.unwrap_or_else(|| {
panic!("{ctx}: warnings[] must contain code={code}.\nenvelope: {env}")
});
let detail = json_string(w, "detail")
.unwrap_or_else(|| panic!("{ctx}: refusal warning must carry a detail.\nenvelope: {env}"));
assert!(
detail.contains("Plug'n'Play"),
"{ctx}: refusal detail should name the Plug'n'Play layout, got: {detail}"
);
if code == "yarn_pnp_unsupported" {
assert!(
detail.contains("yarn patch"),
"{ctx}: refusal detail should point at `yarn patch`, got: {detail}"
);
}
}

/// One mode's scan leg: `scan --json --yes --mode <mode>` on the PnP fixture
/// must keep its documented envelope (exit 0, status success — deliberately
/// unchanged) but surface the explicit refusal warning, and must not touch a
/// single file (no manifest, no lockfile edit, no cache rewrite).
fn scan_pnp_mode_case(mode: &str) {
let dir = tempfile::tempdir().unwrap();
make_yarn_berry_scan_fixture(dir.path());
let before = snapshot_tree(dir.path());

let (code, stdout, stderr) = run_with_env(
dir.path(),
&["scan", "--json", "--yes", "--mode", mode],
&[("SOCKET_TELEMETRY_DISABLED", "1")],
);
assert_eq!(
code, 0,
"mode {mode}: scan exit semantics are deliberately unchanged (exit 0 + warning).\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let env = parse_json_envelope(&stdout);
assert_eq!(
json_string(&env, "status"),
Some("success"),
"mode {mode}: status field deliberately unchanged.\nenvelope: {env}"
);
assert_eq!(
env.get("scannedPackages").and_then(|v| v.as_u64()),
Some(0),
"mode {mode}: PnP packages are undiscoverable, count stays 0.\nenvelope: {env}"
);
assert_scan_pnp_refusal_warning(&env, "yarn_pnp_unsupported", &format!("mode {mode}"));
if mode == "hosted" {
assert_eq!(
env.get("redirect")
.and_then(|r| r.get("redirected"))
.and_then(|v| v.as_u64()),
Some(0),
"mode {mode}: hosted envelope keeps its (empty) redirect block.\nenvelope: {env}"
);
}
assert!(
!dir.path().join(".socket").exists(),
"mode {mode}: a refused scan must not create .socket/ state"
);
assert_eq!(
snapshot_tree(dir.path()),
before,
"mode {mode}: scan on a PnP project must leave every file untouched"
);
}

#[test]
fn scan_agent_mode_on_pnp_project_surfaces_refusal_warning() {
scan_pnp_mode_case("agent");
}

#[test]
fn scan_hosted_mode_on_pnp_project_surfaces_refusal_warning() {
scan_pnp_mode_case("hosted");
}

#[test]
fn scan_vendored_mode_on_pnp_project_surfaces_refusal_warning() {
scan_pnp_mode_case("vendored");
}

/// Human (non-JSON) scan on the same fixture: the refusal must reach stderr
/// so an interactive user sees it, exit code unchanged (0).
#[test]
fn scan_human_mode_on_pnp_project_prints_refusal_to_stderr() {
let dir = tempfile::tempdir().unwrap();
make_yarn_berry_scan_fixture(dir.path());

let (code, stdout, stderr) = run_with_env(
dir.path(),
&["scan"],
&[("SOCKET_TELEMETRY_DISABLED", "1")],
);
assert_eq!(
code, 0,
"human scan stays exit 0.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stderr.contains("yarn_pnp_unsupported"),
"human scan must print the stable refusal code to stderr, got:\n{stderr}"
);
assert!(
stderr.contains("Plug'n'Play") && stderr.contains("yarn patch"),
"human scan stderr must name the layout and the workaround, got:\n{stderr}"
);
}

/// The pnpm twin: pnpm's own `node-linker=pnp` mode writes the same
/// `.pnp.cjs` loader. Its diagnosis must surface through the same warnings
/// channel with its own code (and a pnpm remedy, never `yarn patch`).
#[test]
fn scan_on_pnpm_pnp_project_surfaces_pnpm_refusal_warning() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"name":"pnpm-pnp-fixture","version":"0.0.0","private":true}"#,
)
.unwrap();
std::fs::write(dir.path().join(".pnp.cjs"), b"// stub PnP loader\n").unwrap();
std::fs::write(
dir.path().join("pnpm-lock.yaml"),
"lockfileVersion: '9.0'\n\nsettings:\n autoInstallPeers: true\n",
)
.unwrap();
// The installed pnpm store markers that reclassify the loader as pnpm's
// PnP mode (no crawlable package dirs, so discovery stays empty).
std::fs::create_dir_all(dir.path().join("node_modules").join(".pnpm")).unwrap();
std::fs::write(dir.path().join("node_modules").join(".modules.yaml"), "").unwrap();

let (code, stdout, stderr) = run_with_env(
dir.path(),
&["scan", "--json", "--yes"],
&[("SOCKET_TELEMETRY_DISABLED", "1")],
);
assert_eq!(
code, 0,
"pnpm-PnP scan stays exit 0.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let env = parse_json_envelope(&stdout);
assert_scan_pnp_refusal_warning(&env, "pnpm_pnp_unsupported", "pnpm-pnp");
let warnings = env.get("warnings").and_then(|w| w.as_array()).unwrap();
let detail = warnings
.iter()
.find(|w| json_string(w, "code") == Some("pnpm_pnp_unsupported"))
.and_then(|w| json_string(w, "detail"))
.unwrap();
assert!(
detail.contains("node-linker=pnp"),
"pnpm-pnp refusal must diagnose the pnpm linker, got: {detail}"
);
assert!(
!detail.contains("yarn patch"),
"pnpm-pnp refusal must not recommend a yarn command in a pnpm repo, got: {detail}"
);
}

/// The apply half of the P0: on a PnP checkout WITHOUT a manifest, apply
/// used to exit 0 with the calm `noManifest` status — the documented loud
/// `yarn_pnp_unsupported` refusal (pinned by the tests at the top of this
/// file) sat BELOW the noManifest early-return and was unreachable, because
/// scan never writes a manifest on PnP projects. The refusal must fire
/// first, matching the with-manifest envelope shape exactly.
#[test]
fn apply_without_manifest_on_pnp_project_refuses_loudly() {
let dir = tempfile::tempdir().unwrap();
make_yarn_berry_project(dir.path());
// Deliberately NO .socket/ directory: this is what a PnP project looks
// like after any number of scans (scan cannot discover its packages).

let (code, stdout, stderr) = run(dir.path(), &["apply", "--json"]);
assert_eq!(
code, 1,
"apply on a PnP checkout must refuse loudly even without a manifest.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let env = parse_json_envelope(&stdout);
assert_ne!(
json_string(&env, "status"),
Some("noManifest"),
"the calm noManifest exit must not mask the PnP refusal.\nenvelope: {env}"
);
assert_eq!(
envelope_error_code(&env),
Some("yarn_pnp_unsupported"),
"expected error.code=yarn_pnp_unsupported.\nenvelope: {env}"
);
assert_eq!(
json_string(&env, "status"),
Some("error"),
"expected status=error.\nenvelope: {env}"
);
let error_msg = envelope_error_message(&env)
.unwrap_or_else(|| panic!("error.message missing from envelope: {env}"));
assert!(
error_msg.contains("yarn patch") && error_msg.contains("Plug'n'Play"),
"error message should name `yarn patch` and the Plug'n'Play layout, got: {error_msg}"
);
}

/// Human-mode twin of the no-manifest refusal: exit 1 with the stderr
/// pointer, and the old calm "No .socket folder found" message must not be
/// what the user sees instead.
#[test]
fn apply_without_manifest_on_pnp_project_refuses_in_human_mode() {
let dir = tempfile::tempdir().unwrap();
make_yarn_berry_project(dir.path());
Comment thread
mikolalysenko marked this conversation as resolved.

let (code, stdout, stderr) = run(dir.path(), &["apply"]);
assert_eq!(
code, 1,
"expected exit 1.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
!stdout.contains("No .socket folder found"),
"the calm noManifest message must not mask the PnP refusal, got:\n{stdout}"
);
assert!(
stderr.contains("Plug'n'Play") && stderr.contains("yarn patch"),
"stderr should name the layout and the workaround, got:\n{stderr}"
);
}

/// Control for the two tests above: an in-scope npm patch in the SAME
/// polyglot manifest still refuses. Without this, scoping the detector down
/// to nothing at all would leave every positive test in this file passing
Expand Down