-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathcli_resolution_test.rs
More file actions
286 lines (257 loc) · 10.4 KB
/
Copy pathcli_resolution_test.rs
File metadata and controls
286 lines (257 loc) · 10.4 KB
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
276
277
278
279
280
281
282
283
284
285
286
//! Tests for the build-time and runtime CLI provisioning path.
//!
//! Covers the `COPILOT_CLI_PATH` env override, the build-time-extracted
//! binary used when `bundled-cli` is off, and the embed-mode lazy
//! extraction. Mutating env vars is process-global, so all such tests
//! use `serial_test` to avoid races with each other (and with the e2e
//! tests which also read them).
use std::path::PathBuf;
use github_copilot_sdk::{
CliProgram, Client, ClientOptions, ErrorKind, HAS_BUNDLED_CLI, install_bundled_cli,
};
use serial_test::serial;
fn unset_env(key: &str) {
// SAFETY: these tests are serialized with #[serial(copilot_cli_path)]
// so no other test in this binary mutates COPILOT_CLI_PATH while
// we hold the lock. POSIX `setenv`/`unsetenv` are generally
// thread-safe on modern platforms, and we use `current_thread`
// tokio runtimes to avoid concurrent reads from worker threads.
// This doesn't satisfy the strict Rust 2024 safety contract
// (other tests in the binary may read env vars), but the practical
// race window is negligible.
unsafe { std::env::remove_var(key) };
}
fn set_env(key: &str, value: &str) {
// SAFETY: see `unset_env`.
unsafe { std::env::set_var(key, value) };
}
/// COPILOT_CLI_PATH wins when it points at a real file, regardless of
/// build mode.
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn env_override_resolves_to_pointed_file() {
let tmp = tempfile::NamedTempFile::new().expect("create tempfile");
// resolve.rs only checks `is_file()` for COPILOT_CLI_PATH, so a plain
// tempfile is sufficient — we don't need it to be executable. The
// downstream `Client::start` call will fail to exec an empty file,
// which we tolerate below; we just need to observe that the resolver
// returned the env-override path rather than `BinaryNotFound`.
let path = tmp.path().to_path_buf();
set_env(
"COPILOT_CLI_PATH",
path.to_str().expect("utf-8 tempfile path"),
);
let opts = ClientOptions::default().with_program(CliProgram::Resolve);
// `Client::start` reads the env var via resolve.rs. We don't want to
// actually launch a subprocess against our empty temp file, so go
// through the public API just far enough to observe the resolution.
// The easiest observable behavior is that `Client::start` doesn't
// return `Error::BinaryNotFound` — it'll fail later trying to exec
// the empty file, which we tolerate.
let result = Client::start(opts).await;
unset_env("COPILOT_CLI_PATH");
match result {
Ok(_) => {}
Err(e) => {
let msg = format!("{e}");
assert!(
!msg.contains("not found"),
"expected COPILOT_CLI_PATH to win; got {msg}"
);
}
}
// Drop tmp explicitly so the file outlives the assertions above.
drop(tmp);
let _ = path;
}
/// A stale (non-existent) COPILOT_CLI_PATH falls through to the next
/// resolution source (embed or dev) rather than failing outright.
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn stale_env_override_falls_through() {
set_env("COPILOT_CLI_PATH", "/definitely/does/not/exist/copilot");
let opts = ClientOptions::default().with_program(CliProgram::Resolve);
let result = Client::start(opts).await;
unset_env("COPILOT_CLI_PATH");
// In a normally-configured build (either `bundled-cli` on or off)
// the resolver should find a binary via the next source. Failing
// here would mean fallthrough is broken.
if let Err(e) = &result {
assert!(
!matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
"stale COPILOT_CLI_PATH should fall through; got BinaryNotFound: {e}"
);
}
}
/// With `bundled-cli` off, `build.rs` extracts the binary into the
/// per-user cache and the runtime resolver recomputes its location from
/// `COPILOT_SDK_CLI_VERSION` + the OS-derived binary name. This test
/// mirrors that convention and asserts the file is on disk where the
/// resolver expects to find it.
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
#[test]
fn extracted_binary_present_at_conventional_path() {
let version = env!("COPILOT_SDK_CLI_VERSION");
let binary = if cfg!(windows) {
"copilot.exe"
} else {
"copilot"
};
let sanitized = sanitize_version_for_test(version);
let path = dirs::cache_dir()
.expect("platform cache dir")
.join("github-copilot-sdk")
.join("cli")
.join(sanitized)
.join(binary);
assert!(
path.is_file(),
"expected build.rs to extract the CLI to {} (`bundled-cli` off)",
path.display()
);
}
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
fn sanitize_version_for_test(version: &str) -> String {
version
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c,
_ => '_',
})
.collect()
}
/// With `bundled-cli` off, the resolver locates the build-time-extracted
/// binary without any runtime configuration. Observed via
/// `Client::start`: any outcome other than `BinaryNotFound` means the
/// resolver succeeded.
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn unbundled_resolver_finds_extracted_binary() {
unset_env("COPILOT_CLI_PATH");
unset_env("COPILOT_CLI_EXTRACT_DIR");
let opts = ClientOptions::default().with_program(CliProgram::Resolve);
let result = Client::start(opts).await;
if let Err(e) = result {
assert!(
!matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
"resolver returned BinaryNotFound with `bundled-cli` off: {e}"
);
}
}
/// With `bundled-cli` off, `COPILOT_CLI_EXTRACT_DIR` set at runtime
/// redirects the resolver to look directly under the named directory
/// (no per-version subdir, matching the build-time write semantics).
/// We place a fake `copilot[.exe]` there and assert the resolver picks
/// it up — failing here means the build-time / runtime convention has
/// drifted.
#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))]
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn extract_dir_runtime_override_is_honored() {
let tmp = tempfile::tempdir().expect("create tempdir");
let binary = if cfg!(windows) {
"copilot.exe"
} else {
"copilot"
};
let fake = tmp.path().join(binary);
std::fs::write(&fake, b"").expect("write fake binary");
unset_env("COPILOT_CLI_PATH");
set_env(
"COPILOT_CLI_EXTRACT_DIR",
tmp.path().to_str().expect("utf-8 tempdir path"),
);
let opts = ClientOptions::default().with_program(CliProgram::Resolve);
let result = Client::start(opts).await;
unset_env("COPILOT_CLI_EXTRACT_DIR");
if let Err(e) = result {
assert!(
!matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
"EXTRACT_DIR-redirected resolver returned BinaryNotFound: {e}"
);
}
drop(tmp);
let _ = fake;
}
/// Build-time version pin: `cli-version.txt` (when present) must be a
/// combined snapshot — a `version=X.Y.Z` line plus per-asset hash lines.
/// When absent, build.rs falls through to `../nodejs/package-lock.json` —
/// both are accepted, this test only checks the pin file's format if it's
/// there.
#[test]
fn pin_file_when_present_is_well_formed() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let pin = PathBuf::from(manifest_dir).join("cli-version.txt");
if !pin.is_file() {
// Contributor build path — no assertion needed.
return;
}
let contents = std::fs::read_to_string(&pin).expect("read cli-version.txt");
let mut saw_version = false;
for raw in contents.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line
.split_once('=')
.unwrap_or_else(|| panic!("malformed line: {raw:?}"));
assert!(!value.trim().is_empty(), "empty value for key {key:?}");
if key.trim() == "version" {
saw_version = true;
}
}
assert!(saw_version, "cli-version.txt missing `version=` line");
}
/// With `bundled-cli` on AND a supported target, `install_bundled_cli`
/// returns a real on-disk path and is idempotent across calls.
#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
#[test]
fn install_bundled_cli_returns_extracted_path() {
const { assert!(HAS_BUNDLED_CLI) };
let first = install_bundled_cli().expect("bundled CLI should install");
assert!(
first.is_file(),
"install_bundled_cli returned a path that is not a file: {}",
first.display()
);
let second = install_bundled_cli().expect("second call should also succeed");
assert_eq!(
first, second,
"install_bundled_cli must be idempotent across calls"
);
}
/// `install_bundled_cli` returns the same path the runtime resolver
/// hands to `Client::start` for `CliProgram::Resolve` with no
/// `COPILOT_CLI_PATH` override. Observed indirectly: the binary the
/// public API points at must exist, and `Client::start` must not
/// report `BinaryNotFound` under the same env conditions.
#[cfg(all(feature = "bundled-cli", has_bundled_cli))]
#[tokio::test(flavor = "current_thread")]
#[serial(copilot_cli_path)]
async fn install_bundled_cli_matches_resolver() {
unset_env("COPILOT_CLI_PATH");
unset_env("COPILOT_CLI_EXTRACT_DIR");
let direct = install_bundled_cli().expect("bundled CLI should install");
assert!(direct.is_file());
let opts = ClientOptions::default().with_program(CliProgram::Resolve);
if let Err(e) = Client::start(opts).await {
assert!(
!matches!(e.kind(), ErrorKind::BinaryNotFound { .. }),
"resolver returned BinaryNotFound while install_bundled_cli succeeded: {e}"
);
}
}
/// With `bundled-cli` off (or the target unsupported), the public API
/// reports no bundled CLI and does not fall back to the
/// build-time-extracted dev-cache path that `CliProgram::Resolve` uses.
#[cfg(not(all(feature = "bundled-cli", has_bundled_cli)))]
#[test]
fn install_bundled_cli_is_none_without_embed() {
const { assert!(!HAS_BUNDLED_CLI) };
assert!(
install_bundled_cli().is_none(),
"install_bundled_cli must not fall back to the dev-cache path"
);
}