1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
//! End-to-end tests that drive interactive `dialoguer` prompts via a
//! pseudo-terminal. These exercise the `stdin_is_tty()`-gated
//! confirmation paths in `setup`, `remove`, and `get` that
//! subprocess-with-piped-stdin tests can't reach.
//!
//! PTY support: macOS + Linux. Skipped on Windows.
#![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()
}
/// Spawn the socket-patch binary inside a PTY, send `input`, and
/// collect all output until the child exits. Returns `(exit_code,
/// output)`. The timeout is enforced via a watchdog thread that
/// kills the child if it doesn't exit in time.
///
/// Three pieces compose:
/// * **Reader thread**: `read_to_end` on the master side.
/// Blocks until EOF, which the kernel sends once both the
/// slave fd (dropped here) and the child's last open fd are
/// closed.
/// * **Watchdog thread**: sleeps `timeout` then sends SIGKILL
/// via a cloned ChildKiller. Detaches; no join needed since
/// the killer is idempotent and the child either exits
/// normally first (kill is a no-op) or is killed (we proceed).
/// * **Main thread**: writes input, closes the writer (sends
/// EOF on the child's stdin), blocks on `child.wait()`, then
/// joins the reader.
///
/// No polling loops, no mpsc channels, no fixed-duration sleeps
/// before sending input — the PTY buffers the input until the
/// child reads it, so timing-coupling isn't needed.
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 the slave so the master sees EOF once the child closes its
// own copy of the slave fd on exit.
drop(pair.slave);
// Reader: a single `read_to_end` is sufficient — it blocks until
// EOF, which arrives when (a) the master is dropped (we do that
// below) or (b) the child has exited and its end of the slave is
// closed. The previous design used a chunked read+mpsc loop
// because it interleaved with a try_wait poll; the simplified
// design serializes wait → drop master → read_to_end joins.
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
});
// Watchdog: detach a thread that kills the child after `timeout`.
// The cloned ChildKiller is independent of the main `child`
// handle, so the watchdog can fire without coordinating with the
// main thread. If the child exits naturally first, the kill is a
// no-op against a dead pid.
let mut killer = child.clone_killer();
std::thread::spawn(move || {
std::thread::sleep(timeout);
let _ = killer.kill();
});
// Writer: send input then close. PTY buffers absorb the write so
// no pre-sleep is needed — dialoguer/rustyline will read it when
// their prompt loop polls stdin.
let mut writer = pair.master.take_writer().expect("take writer");
let _ = writer.write_all(input.as_bytes());
let _ = writer.flush();
drop(writer);
// Block until the child exits (watchdog enforces the timeout).
let status = child.wait().expect("child.wait");
// Drop the master so the reader's `read_to_end` sees EOF and
// returns.
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())
}
// ---------------------------------------------------------------------------
// `setup` interactive confirmation
// ---------------------------------------------------------------------------
#[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();
// Without --yes, setup prompts "Proceed with these changes? (y/N): ".
// Sending "y\n" should make it proceed with the update.
let (code, _output) = run_in_pty(
&["setup"],
tmp.path(),
"y\n",
Duration::from_secs(15),
);
assert_eq!(code, 0, "setup with 'y' must succeed");
// package.json should have been updated.
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}"
);
// package.json must be unchanged.
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() {
// Pressing just Enter at the prompt defaults to N (abort).
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");
}
// ---------------------------------------------------------------------------
// `remove` interactive confirmation
// ---------------------------------------------------------------------------
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);
// Manifest should be empty now.
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");
// Manifest must still have the entry.
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"
);
}
// ---------------------------------------------------------------------------
// Apply non-JSON without --yes also exercises confirm() flow,
// even though apply auto-proceeds in non-interactive contexts.
// ---------------------------------------------------------------------------
#[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}"
);
}