#![cfg(unix)]
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
fn binary() -> PathBuf {
env!("CARGO_BIN_EXE_socket-patch").into()
}
fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32, String) {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("openpty");
let mut cmd = CommandBuilder::new(binary());
for a in args {
cmd.arg(a);
}
cmd.cwd(cwd);
cmd.env_remove("SOCKET_API_TOKEN");
let mut child = pair
.slave
.spawn_command(cmd)
.expect("spawn socket-patch in PTY");
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().expect("clone reader");
let reader_handle = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = reader.read_to_end(&mut buf);
buf
});
let mut killer = child.clone_killer();
std::thread::spawn(move || {
std::thread::sleep(timeout);
let _ = killer.kill();
});
let mut writer = pair.master.take_writer().expect("take writer");
let _ = writer.write_all(input.as_bytes());
let _ = writer.flush();
drop(writer);
let status = child.wait().expect("child.wait");
drop(pair.master);
let output = reader_handle.join().expect("reader thread join");
let code = status.exit_code() as i32;
(code, String::from_utf8_lossy(&output).to_string())
}
#[test]
fn setup_interactive_y_proceeds_with_update() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("package.json"),
r#"{ "name": "p", "version": "1.0.0" }"#,
)
.unwrap();
let (code, _output) = run_in_pty(
&["setup"],
tmp.path(),
"y\n",
Duration::from_secs(15),
);
assert_eq!(code, 0, "setup with 'y' must succeed");
let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap();
assert!(
pkg.contains("socket-patch"),
"setup must have written postinstall script; got: {pkg}"
);
}
#[test]
fn setup_interactive_n_aborts_without_update() {
let tmp = tempfile::tempdir().unwrap();
let original = r#"{ "name": "p", "version": "1.0.0" }
"#;
std::fs::write(tmp.path().join("package.json"), original).unwrap();
let (code, output) = run_in_pty(
&["setup"],
tmp.path(),
"n\n",
Duration::from_secs(15),
);
assert_eq!(code, 0, "setup with 'n' must exit cleanly");
assert!(
output.contains("Aborted") || output.contains("aborted"),
"setup must print abort message; got: {output}"
);
let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap();
assert_eq!(pkg, original, "setup 'n' must not modify package.json");
}
#[test]
fn setup_interactive_default_no_aborts() {
let tmp = tempfile::tempdir().unwrap();
let original = r#"{ "name": "p", "version": "1.0.0" }
"#;
std::fs::write(tmp.path().join("package.json"), original).unwrap();
let (code, _output) = run_in_pty(
&["setup"],
tmp.path(),
"\n",
Duration::from_secs(15),
);
assert_eq!(code, 0);
let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap();
assert_eq!(pkg, original, "default-N must not modify package.json");
}
const REMOVE_MANIFEST: &str = r#"{
"patches": {
"pkg:npm/__interactive_remove__@1.0.0": {
"uuid": "11111111-1111-4111-8111-111111111111",
"exportedAt": "2024-01-01T00:00:00Z",
"files": {},
"vulnerabilities": {},
"description": "interactive remove test",
"license": "MIT",
"tier": "free"
}
}
}"#;
fn write_remove_manifest(root: &Path) {
let socket = root.join(".socket");
std::fs::create_dir_all(&socket).unwrap();
std::fs::write(socket.join("manifest.json"), REMOVE_MANIFEST).unwrap();
}
#[test]
fn remove_interactive_y_proceeds() {
let tmp = tempfile::tempdir().unwrap();
write_remove_manifest(tmp.path());
let (code, _output) = run_in_pty(
&["remove", "pkg:npm/__interactive_remove__@1.0.0", "--skip-rollback"],
tmp.path(),
"y\n",
Duration::from_secs(15),
);
assert_eq!(code, 0);
let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap();
let manifest: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(
manifest["patches"]
.as_object()
.map(|p| p.is_empty())
.unwrap_or(false),
"remove 'y' must drop the entry; got: {body}"
);
}
#[test]
fn remove_interactive_n_cancels() {
let tmp = tempfile::tempdir().unwrap();
write_remove_manifest(tmp.path());
let (code, _output) = run_in_pty(
&["remove", "pkg:npm/__interactive_remove__@1.0.0", "--skip-rollback"],
tmp.path(),
"n\n",
Duration::from_secs(15),
);
assert_eq!(code, 0, "remove 'n' must exit cleanly");
let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap();
let manifest: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(
manifest["patches"]
.as_object()
.map(|p| !p.is_empty())
.unwrap_or(true),
"remove 'n' must leave manifest intact"
);
}
#[test]
fn apply_in_pty_with_no_manifest_prints_friendly_message() {
let tmp = tempfile::tempdir().unwrap();
let (code, output) = run_in_pty(
&["apply"],
tmp.path(),
"",
Duration::from_secs(15),
);
assert_eq!(code, 0);
assert!(
output.contains("No .socket folder") || output.contains("skipping"),
"PTY apply no-manifest must print friendly message; got: {output}"
);
}