返回 CodeWhale
diagnostic_read_only.rs
根目录 / crates / tui / tests / diagnostic_read_only.rs
1 //! Process-level regression coverage for read-only diagnostic commands.
2
3 use std::fs;
4 use std::path::PathBuf;
5 use std::process::{Command, Output};
6 use std::sync::{Arc, Mutex, mpsc};
7 use std::thread;
8 use std::time::Duration;
9
10 use axum::body::Bytes;
11 use axum::http::HeaderMap;
12 use axum::routing::post;
13 use axum::{Json, Router};
14 use codewhale_secrets::{FileKeyringStore, KeyringStore};
15 use tempfile::TempDir;
16
17 #[test]
18 fn doctor_text_leaves_a_sealed_home_untouched() {
19 let output = run_sealed_diagnostic(["doctor"]);
20 let stdout = String::from_utf8_lossy(&output.stdout);
21 assert!(stdout.contains("codewhale Doctor"), "stdout:\n{stdout}");
22 }
23
24 #[test]
25 fn doctor_json_leaves_a_sealed_home_untouched() {
26 let output = run_sealed_diagnostic(["doctor", "--json"]);
27 let report: serde_json::Value =
28 serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
29 panic!(
30 "doctor --json must remain machine-readable: {error}\nstdout:\n{}\nstderr:\n{}",
31 String::from_utf8_lossy(&output.stdout),
32 String::from_utf8_lossy(&output.stderr)
33 )
34 });
35 assert_eq!(report["api_connectivity"]["checked"], false);
36 }
37
38 #[test]
39 fn doctor_json_rejects_kimi_code_claude_alias_with_machine_readable_guidance() {
40 let fixture = TempDir::new().expect("fixture root");
41 let workspace = fixture.path().join("workspace");
42 let home = fixture.path().join("home");
43 let codewhale_home = fixture.path().join("isolated-codewhale-home");
44 fs::create_dir_all(&workspace).expect("workspace");
45 let config = workspace.join("kimi-invalid.toml");
46 let config_bytes = br#"provider = "moonshot"
47
48 [providers.moonshot]
49 api_key = "doctor-json-kimi-secret"
50 base_url = "https://api.kimi.com/coding/v1"
51 model = "k3[1m]"
52 "#;
53 fs::write(&config, config_bytes).expect("write invalid Kimi config");
54
55 let mut command = diagnostic_command(&workspace, &home);
56 command
57 .args([
58 "--config",
59 config.to_str().expect("config path"),
60 "doctor",
61 "--json",
62 ])
63 .env("CODEWHALE_HOME", &codewhale_home);
64 let output = command.output().expect("run invalid Kimi doctor json");
65
66 assert!(
67 !output.status.success(),
68 "invalid configuration must return nonzero\nstdout:\n{}\nstderr:\n{}",
69 String::from_utf8_lossy(&output.stdout),
70 String::from_utf8_lossy(&output.stderr)
71 );
72 let report: serde_json::Value = serde_json::from_slice(&output.stdout)
73 .expect("invalid doctor --json output must remain machine-readable");
74 assert_eq!(report["status"], "error");
75 assert_eq!(report["error"]["kind"], "config_validation");
76 let message = report["error"]["message"]
77 .as_str()
78 .expect("config error message");
79 assert!(message.contains("model = \"k3\""), "{message}");
80 assert!(message.contains("context_window = 1048576"), "{message}");
81 assert!(message.contains("plan includes 1M context"), "{message}");
82
83 let stderr = String::from_utf8_lossy(&output.stderr);
84 assert!(
85 stderr.contains("doctor configuration validation failed; see JSON output"),
86 "stderr must point only to the JSON envelope: {stderr}"
87 );
88 for stdout_only_detail in ["k3[1m]", "context_window", "plan includes 1M context"] {
89 assert!(
90 !stderr.contains(stdout_only_detail),
91 "actionable validation details belong only in redacted stdout JSON: {stderr}"
92 );
93 }
94
95 let all_output = format!(
96 "{}\n{}",
97 String::from_utf8_lossy(&output.stdout),
98 String::from_utf8_lossy(&output.stderr)
99 );
100 assert!(!all_output.contains("doctor-json-kimi-secret"));
101 assert_eq!(
102 fs::read(&config).expect("read config after doctor"),
103 config_bytes,
104 "doctor must not rewrite an invalid config"
105 );
106 assert!(!home.exists(), "doctor must not create HOME state");
107 assert!(
108 !codewhale_home.exists(),
109 "doctor must not create CODEWHALE_HOME state"
110 );
111 }
112
113 #[test]
114 fn doctor_json_omits_untrusted_config_validation_details() {
115 let fixture = TempDir::new().expect("fixture root");
116 let workspace = fixture.path().join("workspace");
117 let home = fixture.path().join("home");
118 let codewhale_home = fixture.path().join("isolated-codewhale-home");
119 fs::create_dir_all(&workspace).expect("workspace");
120 let config = workspace.join("untrusted-invalid.toml");
121 let config_bytes = br#"provider = "doctor-untrusted-provider-secret"
122 api_key = "doctor-json-arbitrary-secret"
123 "#;
124 fs::write(&config, config_bytes).expect("write invalid config");
125
126 let mut command = diagnostic_command(&workspace, &home);
127 command
128 .args([
129 "--config",
130 config.to_str().expect("config path"),
131 "doctor",
132 "--json",
133 ])
134 .env("CODEWHALE_HOME", &codewhale_home);
135 let output = command.output().expect("run invalid doctor json");
136
137 assert!(!output.status.success());
138 let report: serde_json::Value =
139 serde_json::from_slice(&output.stdout).expect("machine-readable doctor error");
140 assert_eq!(report["error"]["kind"], "config_validation");
141 assert_eq!(
142 report["error"]["message"],
143 "configuration validation failed; details omitted because configuration errors may contain credential material"
144 );
145 let all_output = format!(
146 "{}\n{}",
147 String::from_utf8_lossy(&output.stdout),
148 String::from_utf8_lossy(&output.stderr)
149 );
150 assert!(!all_output.contains("doctor-untrusted-provider-secret"));
151 assert!(!all_output.contains("doctor-json-arbitrary-secret"));
152 assert_eq!(
153 fs::read(&config).expect("read config after doctor"),
154 config_bytes
155 );
156 assert!(!home.exists());
157 assert!(!codewhale_home.exists());
158 }
159
160 #[test]
161 fn doctor_json_reports_valid_kimi_code_k3_context_override_from_runtime_route() {
162 let fixture = TempDir::new().expect("fixture root");
163 let workspace = fixture.path().join("workspace");
164 let home = fixture.path().join("home");
165 let codewhale_home = fixture.path().join("isolated-codewhale-home");
166 fs::create_dir_all(&workspace).expect("workspace");
167 let config = workspace.join("kimi-valid.toml");
168 let config_bytes = br#"provider = "moonshot"
169
170 [providers.moonshot]
171 api_key = "doctor-json-valid-kimi-secret"
172 base_url = "https://api.kimi.com/coding/v1"
173 model = "k3"
174 context_window = 1048576
175 "#;
176 fs::write(&config, config_bytes).expect("write valid Kimi config");
177
178 let mut command = diagnostic_command(&workspace, &home);
179 command
180 .args([
181 "--config",
182 config.to_str().expect("config path"),
183 "doctor",
184 "--json",
185 ])
186 .env("CODEWHALE_HOME", &codewhale_home);
187 let output = command.output().expect("run valid Kimi doctor json");
188
189 assert!(
190 output.status.success(),
191 "valid Kimi doctor --json failed\nstdout:\n{}\nstderr:\n{}",
192 String::from_utf8_lossy(&output.stdout),
193 String::from_utf8_lossy(&output.stderr)
194 );
195 let report: serde_json::Value =
196 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
197 assert_eq!(report["route"]["model"], "k3");
198 assert_eq!(report["route"]["context_window"]["tokens"], 1_048_576);
199 assert_eq!(report["route"]["context_window"]["source"], "configured");
200 assert!(report["route"]["route_error"].is_null());
201 assert_eq!(report["capability"]["resolved_model"], "k3");
202 assert_eq!(report["capability"]["context_window"], 1_048_576);
203 assert_eq!(report["capability"]["context_window_source"], "configured");
204 assert!(report["capability"]["route_error"].is_null());
205
206 let all_output = format!(
207 "{}\n{}",
208 String::from_utf8_lossy(&output.stdout),
209 String::from_utf8_lossy(&output.stderr)
210 );
211 assert!(!all_output.contains("doctor-json-valid-kimi-secret"));
212 assert_eq!(
213 fs::read(&config).expect("read config after doctor"),
214 config_bytes,
215 "doctor must not rewrite a valid config"
216 );
217 assert!(!home.exists(), "doctor must not create HOME state");
218 assert!(
219 !codewhale_home.exists(),
220 "doctor must not create CODEWHALE_HOME state"
221 );
222 }
223
224 #[test]
225 fn doctor_context_json_leaves_a_sealed_home_untouched() {
226 let output = run_sealed_diagnostic(["doctor", "--context-json"]);
227 let report: serde_json::Value =
228 serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
229 panic!(
230 "doctor --context-json must remain machine-readable: {error}\nstdout:\n{}\nstderr:\n{}",
231 String::from_utf8_lossy(&output.stdout),
232 String::from_utf8_lossy(&output.stderr)
233 )
234 });
235 assert!(
236 report["entries"].is_array(),
237 "doctor --context-json must emit a source map\nstdout:\n{}",
238 String::from_utf8_lossy(&output.stdout)
239 );
240 }
241
242 #[test]
243 fn setup_status_leaves_a_sealed_home_untouched() {
244 let output = run_sealed_diagnostic(["setup", "--status"]);
245 let stdout = String::from_utf8_lossy(&output.stdout);
246 assert!(stdout.contains("Codewhale Status"), "stdout:\n{stdout}");
247 }
248
249 #[test]
250 fn diagnostics_read_home_legacy_settings_without_migrating_them() {
251 for args in [
252 &["doctor"][..],
253 &["doctor", "--json"][..],
254 &["setup", "--status"][..],
255 ] {
256 let fixture = TempDir::new().expect("fixture root");
257 let workspace = fixture.path().join("workspace");
258 let home = fixture.path().join("home");
259 let legacy = home.join(".deepseek").join("settings.toml");
260 let primary_home = home.join(".codewhale");
261 let legacy_bytes = b"default_mode = \"plan\"\nprefer_external_pdftotext = true\n";
262 fs::create_dir_all(&workspace).expect("workspace");
263 fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy directory");
264 fs::write(&legacy, legacy_bytes).expect("legacy settings");
265
266 let output = diagnostic_command(&workspace, &home)
267 .args(args)
268 .output()
269 .expect("run diagnostic against legacy settings");
270 assert!(
271 output.status.success(),
272 "diagnostic {args:?} failed\nstdout:\n{}\nstderr:\n{}",
273 String::from_utf8_lossy(&output.stdout),
274 String::from_utf8_lossy(&output.stderr)
275 );
276
277 match args {
278 ["doctor"] => {
279 let stdout = String::from_utf8_lossy(&output.stdout);
280 assert!(
281 stdout.contains("default_mode=plan (settings)"),
282 "doctor must report the legacy default mode\nstdout:\n{stdout}"
283 );
284 assert!(
285 !stdout.contains("prefer_external_pdftotext"),
286 "doctor must not advertise the removed PDF preference\nstdout:\n{stdout}"
287 );
288 }
289 ["doctor", "--json"] => {
290 let report: serde_json::Value =
291 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
292 assert_eq!(
293 report["setup"]["runtime_posture"]["default_mode"]["value"],
294 "plan"
295 );
296 assert_eq!(
297 report["setup"]["runtime_posture"]["default_mode"]["source"],
298 "settings"
299 );
300 }
301 ["setup", "--status"] => {
302 let stdout = String::from_utf8_lossy(&output.stdout);
303 assert!(
304 stdout.contains("default_mode: plan (settings)"),
305 "setup status must report the legacy default mode\nstdout:\n{stdout}"
306 );
307 }
308 _ => unreachable!("fixed diagnostic command list"),
309 }
310
311 assert_eq!(
312 fs::read(&legacy).expect("legacy settings after diagnostic"),
313 legacy_bytes,
314 "diagnostic {args:?} must not rewrite legacy settings"
315 );
316 assert!(
317 !primary_home.exists(),
318 "diagnostic {args:?} must not create a primary Codewhale home"
319 );
320 }
321 }
322
323 #[test]
324 fn doctor_json_does_not_inherit_an_ambient_legacy_secret_from_an_explicit_home() {
325 let fixture = TempDir::new().expect("fixture root");
326 let workspace = fixture.path().join("workspace");
327 let home = fixture.path().join("home");
328 let codewhale_home = fixture.path().join("isolated-codewhale-home");
329 fs::create_dir_all(&workspace).expect("workspace");
330 let legacy = home.join(".deepseek").join("secrets").join("secrets.json");
331 FileKeyringStore::new(&legacy)
332 .set("deepseek", "synthetic-ambient-legacy-value")
333 .expect("seed ambient legacy secret");
334 let legacy_before = fs::read(&legacy).expect("read legacy secret before doctor");
335
336 let mut command = Command::new(codewhale_tui_binary());
337 command
338 .current_dir(&workspace)
339 .args(["doctor", "--json"])
340 .env_clear()
341 .env("PATH", std::env::var_os("PATH").expect("PATH"))
342 .env("HOME", &home)
343 .env("USERPROFILE", &home)
344 .env("CODEWHALE_HOME", &codewhale_home)
345 .env("CODEWHALE_SECRET_BACKEND", "file")
346 .env(
347 "CODEWHALE_RELEASE_BASE_URL",
348 "https://example.invalid/releases",
349 )
350 .env("DEEPSEEK_TUI_VERSION", env!("CARGO_PKG_VERSION"));
351 preserve_host_rustup_home(&mut command);
352 preserve_host_platform_runtime(&mut command);
353
354 let output = command.output().expect("run isolated doctor --json");
355 assert!(
356 output.status.success(),
357 "isolated doctor --json failed\nstdout:\n{}\nstderr:\n{}",
358 String::from_utf8_lossy(&output.stdout),
359 String::from_utf8_lossy(&output.stderr)
360 );
361 let report: serde_json::Value =
362 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
363 assert_eq!(
364 report["api_key"]["source"], "secret_store_unprobed",
365 "doctor must report only structural eligibility, not an ambient legacy secret from outside an explicit home"
366 );
367 assert_eq!(report["api_key"]["availability"], "not_probed");
368 assert_eq!(
369 fs::read(&legacy).expect("read legacy secret after doctor"),
370 legacy_before,
371 "doctor must not rewrite the ambient legacy secret"
372 );
373 assert!(
374 !codewhale_home.exists(),
375 "doctor must not create an isolated Codewhale home or secret store"
376 );
377 }
378
379 #[test]
380 fn doctor_text_probe_uses_a_legacy_key_without_migrating_it() {
381 let fixture = TempDir::new().expect("fixture root");
382 let workspace = fixture.path().join("workspace");
383 let home = fixture.path().join("home");
384 fs::create_dir_all(&workspace).expect("workspace");
385 let legacy = home.join(".deepseek").join("secrets").join("secrets.json");
386 let primary = home.join(".codewhale").join("secrets").join("secrets.json");
387 FileKeyringStore::new(&legacy)
388 .set("deepseek", "diagnostic-legacy-key")
389 .expect("seed legacy secret");
390 let legacy_before = fs::read(&legacy).expect("read legacy secret before doctor");
391 let server = CompletionServer::start();
392 let base_url = server.base_url();
393 let config = workspace.join("doctor.toml");
394 fs::write(
395 &config,
396 format!(
397 "provider = \"deepseek\"\n[providers.deepseek]\nbase_url = \"{base_url}\"\nmodel = \"deepseek-chat\"\nauth_mode = \"api_key\"\n"
398 ),
399 )
400 .expect("write doctor config");
401
402 let output = diagnostic_command(&workspace, &home)
403 .args([
404 "--config",
405 config.to_str().expect("config path"),
406 "doctor",
407 "--probe-local",
408 ])
409 .output()
410 .expect("run doctor probe");
411 assert!(
412 output.status.success(),
413 "doctor probe failed\nstdout:\n{}\nstderr:\n{}",
414 String::from_utf8_lossy(&output.stdout),
415 String::from_utf8_lossy(&output.stderr)
416 );
417 assert!(
418 String::from_utf8_lossy(&output.stdout).contains("API connection successful"),
419 "stdout:\n{}",
420 String::from_utf8_lossy(&output.stdout)
421 );
422 let requests = server.received_requests();
423 assert_eq!(
424 requests.len(),
425 1,
426 "doctor must make one local probe request"
427 );
428 let authorization = requests[0]
429 .get("authorization")
430 .and_then(|value| value.to_str().ok());
431 assert_eq!(
432 authorization,
433 Some("Bearer diagnostic-legacy-key"),
434 "doctor probe must use the legacy credential without printing it"
435 );
436 assert!(
437 !primary.exists(),
438 "doctor's text connectivity probe must not create a migrated primary secret store"
439 );
440 assert_eq!(
441 fs::read(&legacy).expect("read legacy secret after doctor"),
442 legacy_before,
443 "doctor must not rewrite the legacy secret store"
444 );
445 }
446
447 #[test]
448 fn doctor_json_reports_a_legacy_store_without_reading_or_migrating_it() {
449 let fixture = TempDir::new().expect("fixture root");
450 let workspace = fixture.path().join("workspace");
451 let home = fixture.path().join("home");
452 fs::create_dir_all(&workspace).expect("workspace");
453 let legacy = home.join(".deepseek").join("secrets").join("secrets.json");
454 let primary = home.join(".codewhale").join("secrets").join("secrets.json");
455 FileKeyringStore::new(&legacy)
456 .set("xiaomi-mimo", "tp-diagnostic-legacy-key")
457 .expect("seed legacy Xiaomi secret");
458 let legacy_before = fs::read(&legacy).expect("read legacy secret before doctor");
459 let config = workspace.join("doctor.toml");
460 fs::write(
461 &config,
462 "provider = \"xiaomi-mimo\"\n[providers.xiaomi_mimo]\nmode = \"standard\"\n",
463 )
464 .expect("write doctor config");
465
466 let output = diagnostic_command(&workspace, &home)
467 .args([
468 "--config",
469 config.to_str().expect("config path"),
470 "doctor",
471 "--json",
472 ])
473 .output()
474 .expect("run doctor json");
475 assert!(
476 output.status.success(),
477 "doctor --json failed\nstdout:\n{}\nstderr:\n{}",
478 String::from_utf8_lossy(&output.stdout),
479 String::from_utf8_lossy(&output.stderr)
480 );
481 let report: serde_json::Value =
482 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
483 assert_eq!(report["api_key"]["source"], "secret_store_unprobed");
484 assert_eq!(report["api_key"]["availability"], "not_probed");
485 assert_eq!(
486 report["route"]["auth"]["scheme"], "unknown",
487 "ordinary JSON doctor must not read the legacy key prefix to refine the Xiaomi scheme"
488 );
489 assert_eq!(report["route"]["auth"]["source"], "secret_store_unprobed");
490 assert_eq!(report["route"]["auth"]["availability"], "not_probed");
491 assert!(
492 !primary.exists(),
493 "doctor --json must not migrate a legacy secret while classifying auth"
494 );
495 assert_eq!(
496 fs::read(&legacy).expect("read legacy secret after doctor"),
497 legacy_before,
498 "doctor --json must not rewrite the legacy secret store"
499 );
500 }
501
502 #[test]
503 fn setup_status_reports_a_legacy_store_without_reading_or_migrating_it() {
504 let fixture = TempDir::new().expect("fixture root");
505 let workspace = fixture.path().join("workspace");
506 let home = fixture.path().join("home");
507 fs::create_dir_all(&workspace).expect("workspace");
508 let legacy = home.join(".deepseek").join("secrets").join("secrets.json");
509 let primary = home.join(".codewhale").join("secrets").join("secrets.json");
510 FileKeyringStore::new(&legacy)
511 .set("deepseek", "setup-status-legacy-key")
512 .expect("seed legacy secret");
513 let legacy_before = fs::read(&legacy).expect("read legacy secret before setup");
514
515 let output = diagnostic_command(&workspace, &home)
516 .args(["setup", "--status"])
517 .output()
518 .expect("run setup status");
519 assert!(
520 output.status.success(),
521 "setup --status failed\nstdout:\n{}\nstderr:\n{}",
522 String::from_utf8_lossy(&output.stdout),
523 String::from_utf8_lossy(&output.stderr)
524 );
525 let stdout = String::from_utf8_lossy(&output.stdout);
526 assert!(
527 stdout.contains("api_key: secret store eligible (store not probed)"),
528 "stdout:\n{stdout}"
529 );
530 assert!(
531 stdout.contains("credential availability: not_probed"),
532 "stdout:\n{stdout}"
533 );
534 assert!(
535 !primary.exists(),
536 "setup --status must not create a migrated primary secret store"
537 );
538 assert_eq!(
539 fs::read(&legacy).expect("read legacy secret after setup"),
540 legacy_before,
541 "setup --status must not rewrite the legacy secret store"
542 );
543 }
544
545 #[test]
546 fn doctor_json_stash_honors_an_explicit_codewhale_home() {
547 let fixture = TempDir::new().expect("fixture root");
548 let workspace = fixture.path().join("workspace");
549 let home = fixture.path().join("home");
550 let codewhale_home = fixture.path().join("isolated-codewhale-home");
551 fs::create_dir_all(&workspace).expect("workspace");
552 let ambient_stash = home.join(".codewhale").join("composer_stash.jsonl");
553 fs::create_dir_all(ambient_stash.parent().expect("ambient stash parent"))
554 .expect("ambient stash parent");
555 fs::write(
556 &ambient_stash,
557 r#"{"text":"ambient draft must not be inspected"}"#,
558 )
559 .expect("ambient stash");
560 let ambient_before = fs::read(&ambient_stash).expect("read ambient stash before doctor");
561
562 let mut command = diagnostic_command(&workspace, &home);
563 command
564 .args(["doctor", "--json"])
565 .env("CODEWHALE_HOME", &codewhale_home);
566 let output = command.output().expect("run isolated doctor json");
567 assert!(
568 output.status.success(),
569 "isolated doctor --json failed\nstdout:\n{}\nstderr:\n{}",
570 String::from_utf8_lossy(&output.stdout),
571 String::from_utf8_lossy(&output.stderr)
572 );
573 let report: serde_json::Value =
574 serde_json::from_slice(&output.stdout).expect("machine-readable doctor report");
575 assert_eq!(
576 report["storage"]["stash"]["path"],
577 codewhale_home
578 .join("composer_stash.jsonl")
579 .display()
580 .to_string()
581 );
582 assert_eq!(report["storage"]["stash"]["present"], false);
583 assert_eq!(report["storage"]["stash"]["count"], 0);
584 assert!(report["storage"]["stash"]["error"].is_null());
585 assert!(
586 !String::from_utf8_lossy(&output.stdout).contains("ambient draft must not be inspected"),
587 "doctor must not inspect an ambient stash outside explicit CODEWHALE_HOME"
588 );
589 assert_eq!(
590 fs::read(&ambient_stash).expect("read ambient stash after doctor"),
591 ambient_before,
592 "doctor must not rewrite the ambient stash"
593 );
594 assert!(
595 !codewhale_home.exists(),
596 "a diagnostic must not create an explicit stash home"
597 );
598 }
599
600 fn run_sealed_diagnostic<const N: usize>(args: [&str; N]) -> Output {
601 let fixture = TempDir::new().expect("fixture root");
602 let workspace = fixture.path().join("workspace");
603 let sealed_home = fixture.path().join("sealed-home");
604 let codewhale_home = fixture.path().join("sealed-codewhale-home");
605 std::fs::create_dir_all(&workspace).expect("workspace");
606
607 let mut command = Command::new(codewhale_tui_binary());
608 command
609 .current_dir(&workspace)
610 .args(args)
611 .env_clear()
612 .env("PATH", std::env::var_os("PATH").expect("PATH"))
613 .env("HOME", &sealed_home)
614 .env("USERPROFILE", &sealed_home)
615 .env("CODEWHALE_HOME", &codewhale_home)
616 .env("CODEWHALE_SECRET_BACKEND", "file")
617 // Keep the text doctor command offline: the release crate treats this
618 // as a pinned mirror version and does not issue a metadata request.
619 .env(
620 "CODEWHALE_RELEASE_BASE_URL",
621 "https://example.invalid/releases",
622 )
623 .env("DEEPSEEK_TUI_VERSION", env!("CARGO_PKG_VERSION"));
624 preserve_host_rustup_home(&mut command);
625 preserve_host_platform_runtime(&mut command);
626
627 let output = command.output().expect("run sealed diagnostic");
628 assert!(
629 output.status.success(),
630 "diagnostic {args:?} failed\nstdout:\n{}\nstderr:\n{}",
631 String::from_utf8_lossy(&output.stdout),
632 String::from_utf8_lossy(&output.stderr)
633 );
634 assert!(
635 !sealed_home.exists(),
636 "diagnostic {args:?} must not create a HOME tree at {}",
637 sealed_home.display()
638 );
639 assert!(
640 !codewhale_home.exists(),
641 "diagnostic {args:?} must not create CODEWHALE_HOME or a secrets store at {}",
642 codewhale_home.display()
643 );
644 output
645 }
646
647 fn diagnostic_command(workspace: &std::path::Path, home: &std::path::Path) -> Command {
648 let mut command = Command::new(codewhale_tui_binary());
649 command
650 .current_dir(workspace)
651 .env_clear()
652 .env("PATH", std::env::var_os("PATH").expect("PATH"))
653 .env("HOME", home)
654 .env("USERPROFILE", home)
655 .env("CODEWHALE_SECRET_BACKEND", "file")
656 .env(
657 "CODEWHALE_RELEASE_BASE_URL",
658 "https://example.invalid/releases",
659 )
660 .env("DEEPSEEK_TUI_VERSION", env!("CARGO_PKG_VERSION"));
661 preserve_host_rustup_home(&mut command);
662 preserve_host_platform_runtime(&mut command);
663 command
664 }
665
666 struct CompletionServer {
667 base_url: String,
668 requests: Arc<Mutex<Vec<HeaderMap>>>,
669 shutdown: Option<tokio::sync::oneshot::Sender<()>>,
670 owner: Option<thread::JoinHandle<()>>,
671 }
672
673 impl CompletionServer {
674 fn start() -> Self {
675 let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
676 let (shutdown, shutdown_receiver) = tokio::sync::oneshot::channel();
677 let requests = Arc::new(Mutex::new(Vec::new()));
678 let server_requests = Arc::clone(&requests);
679 let owner = thread::spawn(move || {
680 let runtime = tokio::runtime::Builder::new_multi_thread()
681 .worker_threads(2)
682 .enable_all()
683 .build()
684 .expect("local probe runtime");
685 runtime.block_on(async move {
686 let app = Router::new().route(
687 "/v1/chat/completions",
688 post(move |headers: HeaderMap, body: Bytes| {
689 let requests = Arc::clone(&server_requests);
690 async move {
691 // Extracting Bytes makes Axum drain the complete request body
692 // before replying. Preserve only headers for the credential
693 // assertion; the request payload itself is intentionally dropped.
694 drop(body);
695 requests
696 .lock()
697 .expect("local probe request lock")
698 .push(headers);
699 Json(serde_json::json!({
700 "id": "doctor",
701 "object": "chat.completion",
702 "created": 0,
703 "model": "deepseek-chat",
704 "choices": [{
705 "index": 0,
706 "message": {"role": "assistant", "content": "ok"},
707 "finish_reason": "stop"
708 }],
709 "usage": {
710 "prompt_tokens": 1,
711 "completion_tokens": 1,
712 "total_tokens": 2
713 }
714 }))
715 }
716 }),
717 );
718 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await;
719 let listener = listener.expect("bind local probe server");
720 let address = listener.local_addr().expect("local probe address");
721 ready_sender
722 .send(format!("http://{address}/v1"))
723 .expect("publish local probe address");
724 axum::serve(listener, app)
725 .with_graceful_shutdown(async {
726 let _ = shutdown_receiver.await;
727 })
728 .await
729 .expect("serve local probe request");
730 });
731 });
732 let base_url = ready_receiver
733 .recv_timeout(Duration::from_secs(10))
734 .expect("local probe server must start");
735 Self {
736 base_url,
737 requests,
738 shutdown: Some(shutdown),
739 owner: Some(owner),
740 }
741 }
742
743 fn base_url(&self) -> String {
744 self.base_url.clone()
745 }
746
747 fn received_requests(&self) -> Vec<HeaderMap> {
748 self.requests
749 .lock()
750 .expect("local probe request lock")
751 .clone()
752 }
753 }
754
755 impl Drop for CompletionServer {
756 fn drop(&mut self) {
757 if let Some(shutdown) = self.shutdown.take() {
758 let _ = shutdown.send(());
759 }
760 if let Some(owner) = self.owner.take() {
761 let result = owner.join();
762 if !thread::panicking() {
763 result.expect("stop local probe server");
764 }
765 }
766 }
767 }
768
769 /// A rustup shim may initialize its own toolchain state below `$HOME` when
770 /// `doctor` asks `rustc --version`. Preserve an already-configured toolchain
771 /// root so this test isolates Codewhale's own state contract.
772 fn preserve_host_rustup_home(command: &mut Command) {
773 let rustup_home = std::env::var_os("RUSTUP_HOME")
774 .map(PathBuf::from)
775 .or_else(|| {
776 std::env::var_os("HOME")
777 .map(PathBuf::from)
778 .map(|home| home.join(".rustup"))
779 .filter(|path| path.is_dir())
780 });
781 if let Some(rustup_home) = rustup_home {
782 command.env("RUSTUP_HOME", rustup_home);
783 }
784 }
785
786 /// `env_clear` is part of these tests' credential-isolation boundary, but a
787 /// Windows child still needs the non-secret OS root variables used to locate
788 /// platform networking components. Without them a reqwest client can fail its
789 /// loopback connection before the local fixture ever receives a request.
790 fn preserve_host_platform_runtime(_command: &mut Command) {
791 #[cfg(windows)]
792 for name in ["SystemRoot", "WINDIR"] {
793 if let Some(value) = std::env::var_os(name) {
794 _command.env(name, value);
795 }
796 }
797 }
798
799 fn codewhale_tui_binary() -> PathBuf {
800 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") {
801 return PathBuf::from(path);
802 }
803 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") {
804 return PathBuf::from(path);
805 }
806
807 let mut path = std::env::current_exe().expect("current test executable path");
808 path.pop();
809 if path.ends_with("deps") {
810 path.pop();
811 }
812 path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
813 path
814 }
815
815 lines RUST