Skip to content

Commit da64192

Browse files
committed
test(cli): pin RemoveArgs/SetupArgs flag surface + run() error paths
Adds two parser-snapshot integration test files that lock in every flag in the `remove` and `setup` tables of CLI_CONTRACT.md (long + short forms, defaults) plus the documented no-network exit paths: - crates/socket-patch-cli/tests/cli_parse_remove.rs (16 tests): defaults with PURL or UUID positional, every flag in long + short form (`-y`/`--yes`, `-g`/`--global`, `-m`/`--manifest-path`, `--cwd`, `--skip-rollback`, `--json`, `--global-prefix`), an all-flags-combined case, missing-positional → MissingRequiredArgument, unknown flag → UnknownArgument, and an async `run()` against an empty tempdir asserting exit code 1 for missing manifest. - crates/socket-patch-cli/tests/cli_parse_setup.rs (11 tests): defaults, every flag in long + short form (`-d`/`--dry-run`, `-y`/`--yes`, `--cwd`, `--json`), all-flags-combined, unknown flag → UnknownArgument, an async `run()` against an empty tempdir asserting exit 0, and a subprocess test using `env!("CARGO_BIN_EXE_socket-patch")` that parses the `--json` output and asserts the documented `status: "no_files"` shape (updated/alreadyConfigured/errors all 0, files == []). Both files are net-new and test-only — no production code touched. Verified from /tmp/spw/remove-setup: - cargo build --workspace --all-features: clean - cargo clippy --workspace --all-features -- -D warnings: clean - cargo test --workspace --all-features --lib --tests: 415 lib + 16 cli_parse_remove + 11 cli_parse_setup + all existing pass Assisted-by: Claude Code:claude-opus-4-7
1 parent 0bd4f50 commit da64192

2 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
//! Parser-level contract tests for `socket-patch remove`.
2+
//!
3+
//! Locks in every flag in the `RemoveArgs` table from
4+
//! `crates/socket-patch-cli/CLI_CONTRACT.md` (long + short forms, defaults)
5+
//! and exercises one no-network `run()` error path (missing manifest → 1).
6+
//!
7+
//! These tests deliberately avoid spawning the binary so they run in the
8+
//! default `cargo test` set (no `--ignored` required) and stay fast.
9+
10+
use clap::Parser;
11+
use socket_patch_cli::commands::remove::{run, RemoveArgs};
12+
use socket_patch_cli::{Cli, Commands};
13+
use std::path::PathBuf;
14+
15+
fn parse_remove(extra: &[&str]) -> RemoveArgs {
16+
let mut argv = vec!["socket-patch", "remove"];
17+
argv.extend_from_slice(extra);
18+
let cli = Cli::try_parse_from(&argv).expect("parse");
19+
match cli.command {
20+
Commands::Remove(a) => a,
21+
_ => panic!("expected Remove"),
22+
}
23+
}
24+
25+
// ---------------------------------------------------------------------------
26+
// Defaults
27+
// ---------------------------------------------------------------------------
28+
29+
#[test]
30+
fn defaults_with_purl_positional() {
31+
let args = parse_remove(&["pkg:npm/foo@1"]);
32+
assert_eq!(args.identifier, "pkg:npm/foo@1");
33+
assert_eq!(args.cwd, PathBuf::from("."));
34+
assert_eq!(args.manifest_path, ".socket/manifest.json");
35+
assert!(!args.skip_rollback);
36+
assert!(!args.yes);
37+
assert!(!args.global);
38+
assert_eq!(args.global_prefix, None);
39+
assert!(!args.json);
40+
}
41+
42+
#[test]
43+
fn positional_uuid_stored_in_identifier() {
44+
let args = parse_remove(&["80630680-4da6-45f9-bba8-b888e0ffd58c"]);
45+
assert_eq!(args.identifier, "80630680-4da6-45f9-bba8-b888e0ffd58c");
46+
// Everything else still at default — `remove` does not auto-detect the
47+
// identifier shape at parse time; the runtime branch on `pkg:` happens
48+
// inside `run()`.
49+
assert_eq!(args.cwd, PathBuf::from("."));
50+
assert_eq!(args.manifest_path, ".socket/manifest.json");
51+
assert!(!args.skip_rollback);
52+
assert!(!args.yes);
53+
assert!(!args.global);
54+
assert_eq!(args.global_prefix, None);
55+
assert!(!args.json);
56+
}
57+
58+
// ---------------------------------------------------------------------------
59+
// Flag forms — each one in the contract table must have a test
60+
// ---------------------------------------------------------------------------
61+
62+
#[test]
63+
fn yes_short_form() {
64+
let args = parse_remove(&["pkg:npm/foo@1", "-y"]);
65+
assert!(args.yes);
66+
}
67+
68+
#[test]
69+
fn yes_long_form() {
70+
let args = parse_remove(&["pkg:npm/foo@1", "--yes"]);
71+
assert!(args.yes);
72+
}
73+
74+
#[test]
75+
fn global_short_form() {
76+
let args = parse_remove(&["pkg:npm/foo@1", "-g"]);
77+
assert!(args.global);
78+
}
79+
80+
#[test]
81+
fn global_long_form() {
82+
let args = parse_remove(&["pkg:npm/foo@1", "--global"]);
83+
assert!(args.global);
84+
}
85+
86+
#[test]
87+
fn manifest_path_short_form() {
88+
let args = parse_remove(&["pkg:npm/foo@1", "-m", "custom/manifest.json"]);
89+
assert_eq!(args.manifest_path, "custom/manifest.json");
90+
}
91+
92+
#[test]
93+
fn manifest_path_long_form() {
94+
let args = parse_remove(&[
95+
"pkg:npm/foo@1",
96+
"--manifest-path",
97+
"custom/manifest.json",
98+
]);
99+
assert_eq!(args.manifest_path, "custom/manifest.json");
100+
}
101+
102+
#[test]
103+
fn cwd_long_form() {
104+
let args = parse_remove(&["pkg:npm/foo@1", "--cwd", "/tmp/x"]);
105+
assert_eq!(args.cwd, PathBuf::from("/tmp/x"));
106+
}
107+
108+
#[test]
109+
fn skip_rollback_long_form() {
110+
let args = parse_remove(&["pkg:npm/foo@1", "--skip-rollback"]);
111+
assert!(args.skip_rollback);
112+
}
113+
114+
#[test]
115+
fn json_long_form() {
116+
let args = parse_remove(&["pkg:npm/foo@1", "--json"]);
117+
assert!(args.json);
118+
}
119+
120+
#[test]
121+
fn global_prefix_long_form() {
122+
let args = parse_remove(&[
123+
"pkg:npm/foo@1",
124+
"--global-prefix",
125+
"/opt/node-global",
126+
]);
127+
assert_eq!(args.global_prefix, Some(PathBuf::from("/opt/node-global")));
128+
}
129+
130+
#[test]
131+
fn all_flags_combined() {
132+
let args = parse_remove(&[
133+
"pkg:npm/foo@1",
134+
"--cwd",
135+
"/tmp/x",
136+
"-m",
137+
"custom/manifest.json",
138+
"--skip-rollback",
139+
"-y",
140+
"-g",
141+
"--global-prefix",
142+
"/opt/node-global",
143+
"--json",
144+
]);
145+
assert_eq!(args.identifier, "pkg:npm/foo@1");
146+
assert_eq!(args.cwd, PathBuf::from("/tmp/x"));
147+
assert_eq!(args.manifest_path, "custom/manifest.json");
148+
assert!(args.skip_rollback);
149+
assert!(args.yes);
150+
assert!(args.global);
151+
assert_eq!(args.global_prefix, Some(PathBuf::from("/opt/node-global")));
152+
assert!(args.json);
153+
}
154+
155+
// ---------------------------------------------------------------------------
156+
// Failure paths
157+
// ---------------------------------------------------------------------------
158+
159+
#[test]
160+
fn missing_required_positional_is_error() {
161+
let result = Cli::try_parse_from(["socket-patch", "remove"]);
162+
let err = match result {
163+
Ok(_) => panic!("remove without identifier must fail"),
164+
Err(e) => e,
165+
};
166+
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
167+
}
168+
169+
#[test]
170+
fn unknown_flag_is_error() {
171+
let result = Cli::try_parse_from([
172+
"socket-patch",
173+
"remove",
174+
"pkg:npm/foo@1",
175+
"--not-a-real-flag",
176+
]);
177+
let err = match result {
178+
Ok(_) => panic!("unknown flag must fail"),
179+
Err(e) => e,
180+
};
181+
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
182+
}
183+
184+
// ---------------------------------------------------------------------------
185+
// Async run() — no-network error path
186+
// ---------------------------------------------------------------------------
187+
188+
#[tokio::test]
189+
async fn run_missing_manifest_exits_one() {
190+
let tempdir = tempfile::tempdir().expect("tempdir");
191+
let args = RemoveArgs {
192+
identifier: "pkg:npm/foo@1".to_string(),
193+
cwd: tempdir.path().to_path_buf(),
194+
manifest_path: ".socket/manifest.json".to_string(),
195+
skip_rollback: false,
196+
yes: true,
197+
global: false,
198+
global_prefix: None,
199+
json: true,
200+
};
201+
let exit = run(args).await;
202+
assert_eq!(exit, 1, "missing manifest must exit 1");
203+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Parser-level contract tests for `socket-patch setup`.
2+
//!
3+
//! Locks in every flag in the `SetupArgs` table from
4+
//! `crates/socket-patch-cli/CLI_CONTRACT.md` (long + short forms, defaults)
5+
//! and exercises two no-network `run()` paths:
6+
//!
7+
//! 1. Calling `run()` directly against an empty tempdir → exit 0.
8+
//! 2. Spawning the binary against the same empty tempdir with `--json` and
9+
//! asserting the documented `status: "no_files"` shape.
10+
//!
11+
//! These tests deliberately stay off the network so they run in the default
12+
//! `cargo test` set (no `--ignored` required).
13+
14+
use clap::Parser;
15+
use socket_patch_cli::commands::setup::{run, SetupArgs};
16+
use socket_patch_cli::{Cli, Commands};
17+
use std::path::PathBuf;
18+
use std::process::Command;
19+
20+
fn parse_setup(extra: &[&str]) -> SetupArgs {
21+
let mut argv = vec!["socket-patch", "setup"];
22+
argv.extend_from_slice(extra);
23+
let cli = Cli::try_parse_from(&argv).expect("parse");
24+
match cli.command {
25+
Commands::Setup(a) => a,
26+
_ => panic!("expected Setup"),
27+
}
28+
}
29+
30+
// ---------------------------------------------------------------------------
31+
// Defaults
32+
// ---------------------------------------------------------------------------
33+
34+
#[test]
35+
fn defaults_with_no_flags() {
36+
let args = parse_setup(&[]);
37+
assert_eq!(args.cwd, PathBuf::from("."));
38+
assert!(!args.dry_run);
39+
assert!(!args.yes);
40+
assert!(!args.json);
41+
}
42+
43+
// ---------------------------------------------------------------------------
44+
// Flag forms — each one in the contract table must have a test
45+
// ---------------------------------------------------------------------------
46+
47+
#[test]
48+
fn dry_run_short_form() {
49+
let args = parse_setup(&["-d"]);
50+
assert!(args.dry_run);
51+
}
52+
53+
#[test]
54+
fn dry_run_long_form() {
55+
let args = parse_setup(&["--dry-run"]);
56+
assert!(args.dry_run);
57+
}
58+
59+
#[test]
60+
fn yes_short_form() {
61+
let args = parse_setup(&["-y"]);
62+
assert!(args.yes);
63+
}
64+
65+
#[test]
66+
fn yes_long_form() {
67+
let args = parse_setup(&["--yes"]);
68+
assert!(args.yes);
69+
}
70+
71+
#[test]
72+
fn cwd_long_form() {
73+
let args = parse_setup(&["--cwd", "/tmp/x"]);
74+
assert_eq!(args.cwd, PathBuf::from("/tmp/x"));
75+
}
76+
77+
#[test]
78+
fn json_long_form() {
79+
let args = parse_setup(&["--json"]);
80+
assert!(args.json);
81+
}
82+
83+
#[test]
84+
fn all_flags_combined() {
85+
let args = parse_setup(&["--cwd", "/tmp/x", "-d", "-y", "--json"]);
86+
assert_eq!(args.cwd, PathBuf::from("/tmp/x"));
87+
assert!(args.dry_run);
88+
assert!(args.yes);
89+
assert!(args.json);
90+
}
91+
92+
// ---------------------------------------------------------------------------
93+
// Failure paths
94+
// ---------------------------------------------------------------------------
95+
96+
#[test]
97+
fn unknown_flag_is_error() {
98+
let result = Cli::try_parse_from(["socket-patch", "setup", "--not-a-real-flag"]);
99+
let err = match result {
100+
Ok(_) => panic!("unknown flag must fail"),
101+
Err(e) => e,
102+
};
103+
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
104+
}
105+
106+
// ---------------------------------------------------------------------------
107+
// Async run() — empty tempdir, no package.json files → exit 0
108+
// ---------------------------------------------------------------------------
109+
110+
#[tokio::test]
111+
async fn run_empty_tempdir_exits_zero() {
112+
let tempdir = tempfile::tempdir().expect("tempdir");
113+
let args = SetupArgs {
114+
cwd: tempdir.path().to_path_buf(),
115+
dry_run: false,
116+
yes: true,
117+
json: true,
118+
};
119+
let exit = run(args).await;
120+
assert_eq!(
121+
exit, 0,
122+
"empty tempdir (no package.json) must exit 0 with status 'no_files'"
123+
);
124+
}
125+
126+
// ---------------------------------------------------------------------------
127+
// Subprocess: lock the JSON contract shape for `status: no_files`.
128+
// ---------------------------------------------------------------------------
129+
130+
#[test]
131+
fn subprocess_no_files_json_shape() {
132+
let tempdir = tempfile::tempdir().expect("tempdir");
133+
let exe = env!("CARGO_BIN_EXE_socket-patch");
134+
let output = Command::new(exe)
135+
.arg("setup")
136+
.arg("--cwd")
137+
.arg(tempdir.path())
138+
.arg("--json")
139+
.arg("--yes")
140+
.output()
141+
.expect("spawn socket-patch");
142+
assert!(
143+
output.status.success(),
144+
"setup against empty tempdir must succeed, stderr: {}",
145+
String::from_utf8_lossy(&output.stderr)
146+
);
147+
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
148+
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
149+
panic!("stdout must be JSON, got {stdout:?}: {e}");
150+
});
151+
assert_eq!(
152+
v["status"], "no_files",
153+
"status must be 'no_files' for empty tempdir; full payload: {v}"
154+
);
155+
assert_eq!(v["updated"], 0);
156+
assert_eq!(v["alreadyConfigured"], 0);
157+
assert_eq!(v["errors"], 0);
158+
assert!(v["files"].is_array(), "'files' must be an array");
159+
assert_eq!(
160+
v["files"].as_array().expect("array").len(),
161+
0,
162+
"'files' must be an empty array for status 'no_files'"
163+
);
164+
}

0 commit comments

Comments
 (0)